diff --git a/cmd/autoresume_test.go b/cmd/autoresume_test.go index b6d728968..66b940bfb 100644 --- a/cmd/autoresume_test.go +++ b/cmd/autoresume_test.go @@ -7,11 +7,11 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" ) func TestCmd_AutoResume_Execution(t *testing.T) { @@ -46,19 +46,19 @@ func TestCmd_AutoResume_Execution(t *testing.T) { } // 3. Configure State DB - state.CloseDB() // Ensure clean state + store.CloseDB() // Ensure clean state dbPath := filepath.Join(surgeDir, "state", "surge.db") if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { t.Fatal(err) } - state.Configure(dbPath) + store.Configure(dbPath) // 4. Seed DB with a paused download testID := "cmd-resume-id-1" testURL := "http://example.com/cmd-resume.zip" testDest := filepath.Join(tmpDir, "cmd-resume.zip") - manualState := &types.DownloadState{ + manualState := &types.DownloadRecord{ ID: testID, URL: testURL, Filename: "cmd-resume.zip", @@ -68,7 +68,7 @@ func TestCmd_AutoResume_Execution(t *testing.T) { PausedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(), } - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: testID, URL: testURL, DestPath: testDest, @@ -79,34 +79,19 @@ func TestCmd_AutoResume_Execution(t *testing.T) { }); err != nil { t.Fatal(err) } - if err := state.SaveState(testURL, testDest, manualState); err != nil { + if err := store.SaveState(testURL, testDest, manualState); err != nil { t.Fatal(err) } // 5. Initialize GlobalPool + GlobalService - GlobalProgressCh = make(chan any, 10) - GlobalPool = download.NewWorkerPool(GlobalProgressCh, 4) - GlobalService = core.NewLocalDownloadServiceWithInput(GlobalPool, GlobalProgressCh) + GlobalProgressCh = make(chan types.DownloadEvent, 10) + GlobalPool = scheduler.New(GlobalProgressCh, 4) + + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + GlobalService = service.NewLocalDownloadService(GlobalLifecycle) - GlobalLifecycle = processing.NewLifecycleManager(nil, nil, nil) - GlobalLifecycle.SetEngineHooks(processing.EngineHooks{ - Pause: GlobalPool.Pause, - ExtractPausedConfig: GlobalPool.ExtractPausedConfig, - AddConfig: GlobalPool.Add, - GetStatus: GlobalPool.GetStatus, - Cancel: GlobalPool.Cancel, - UpdateURL: GlobalPool.UpdateURL, - PublishEvent: GlobalService.Publish, - }) - if svc, ok := GlobalService.(*core.LocalDownloadService); ok { - svc.SetLifecycleHooks(core.LifecycleHooks{ - Pause: GlobalLifecycle.Pause, - Resume: GlobalLifecycle.Resume, - ResumeBatch: GlobalLifecycle.ResumeBatch, - Cancel: GlobalLifecycle.Cancel, - UpdateURL: GlobalLifecycle.UpdateURL, - }) - } defer func() { _ = GlobalService.Shutdown() GlobalService = nil diff --git a/cmd/cli_test.go b/cmd/cli_test.go index e2c933deb..cfa29639a 100644 --- a/cmd/cli_test.go +++ b/cmd/cli_test.go @@ -14,11 +14,12 @@ import ( "testing" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -52,7 +53,7 @@ func TestResolveDownloadID_Remote(t *testing.T) { if err := config.EnsureDirs(); err != nil { t.Fatalf("EnsureDirs failed: %v", err) } - state.Configure(filepath.Join(tempDir, "surge.db")) + store.Configure(filepath.Join(tempDir, "surge.db")) saveActivePort(port) defer removeActivePort() @@ -92,8 +93,8 @@ func TestResolveDownloadID_RemoteStillWorksWhenDBUnavailable(t *testing.T) { globalToken = origToken }) - state.CloseDB() - state.Configure(filepath.Join(t.TempDir(), "missing", "surge.db")) // Intentionally invalid path + store.CloseDB() + store.Configure(filepath.Join(t.TempDir(), "missing", "surge.db")) // Intentionally invalid path full, err := resolveDownloadID("ddeeff") if err != nil { @@ -107,11 +108,11 @@ func TestResolveDownloadID_RemoteStillWorksWhenDBUnavailable(t *testing.T) { func TestResolveDownloadID_StrictRemoteDoesNotFallbackToDBOnRemoteError(t *testing.T) { setupIsolatedCmdState(t) - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: "11223344-1234-5678-90ab-cdef12345678", Filename: "db-only.bin", } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { t.Fatalf("failed to seed db entry: %v", err) } @@ -146,11 +147,11 @@ func TestResolveDownloadID_StrictRemoteDoesNotFallbackToDBOnRemoteError(t *testi func TestResolveDownloadID_LocalModeFallsBackToDBWhenRemoteListFails(t *testing.T) { setupIsolatedCmdState(t) - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: "99aabbcc-1234-5678-90ab-cdef12345678", Filename: "fallback.bin", } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { t.Fatalf("failed to seed db entry: %v", err) } @@ -436,43 +437,7 @@ func TestPrintDownloadDetail_TextAndJSON(t *testing.T) { } } -func TestRmClean_Offline_Works(t *testing.T) { - setupIsolatedCmdState(t) - removeActivePort() // Ensure offline mode - - completed := types.DownloadEntry{ - ID: "rm-clean-offline-id", - URL: "https://example.com/completed.bin", - Filename: "completed.bin", - DestPath: filepath.Join(t.TempDir(), "completed.bin"), - Status: "completed", - TotalSize: 100, - Downloaded: 100, - CompletedAt: 1, - } - if err := state.AddToMasterList(completed); err != nil { - t.Fatalf("failed to seed completed download: %v", err) - } - if err := rmCmd.Flags().Set("clean", "true"); err != nil { - t.Fatalf("failed to set clean flag: %v", err) - } - defer func() { _ = rmCmd.Flags().Set("clean", "false") }() - - _ = captureStdout(t, func() { - if err := rmCmd.RunE(rmCmd, []string{}); err != nil { - t.Fatalf("rm clean failed: %v", err) - } - }) - - entry, err := state.GetDownload(completed.ID) - if err != nil { - t.Fatalf("failed to query completed entry after clean: %v", err) - } - if entry != nil { - t.Fatalf("expected completed entry to be removed by offline --clean, got %+v", entry) - } -} func TestAddCmdRunE_ReturnsExpectedErrors(t *testing.T) { t.Run("no running server", func(t *testing.T) { @@ -485,10 +450,11 @@ func TestAddCmdRunE_ReturnsExpectedErrors(t *testing.T) { err := addCmd.RunE(addCmd, []string{"https://example.com/file.zip"}) if err == nil { - t.Fatal("expected add command to return an error when no server is running") + t.Errorf("expected add command to return an error when no server is running") + return } if !strings.Contains(err.Error(), "surge is not running locally") { - t.Fatalf("unexpected error: %v", err) + t.Errorf("unexpected error: %v", err) } }) @@ -506,10 +472,11 @@ func TestAddCmdRunE_ReturnsExpectedErrors(t *testing.T) { err := addCmd.RunE(addCmd, nil) if err == nil { - t.Fatal("expected add command to return an error for a missing batch file") + t.Errorf("expected add command to return an error for a missing batch file") + return } if !strings.Contains(err.Error(), "error reading batch file") { - t.Fatalf("unexpected error: %v", err) + t.Errorf("unexpected error: %v", err) } }) } @@ -546,6 +513,16 @@ func TestActionCommandsRunE_ReturnNoServerErrors(t *testing.T) { return rmCmd.RunE(rmCmd, []string{"deadbeef"}) }, }, + { + name: "rm_clean", + run: func() error { + if err := rmCmd.Flags().Set("clean", "true"); err != nil { + return err + } + defer func() { _ = rmCmd.Flags().Set("clean", "false") }() + return rmCmd.RunE(rmCmd, []string{}) + }, + }, { name: "refresh", run: func() error { @@ -695,12 +672,12 @@ func TestActionCommandsRunE_ReturnAmbiguousIDErrors(t *testing.T) { saveActivePort(port) t.Cleanup(removeActivePort) - entries := []types.DownloadEntry{ + entries := []types.DownloadRecord{ {ID: "deadbeef-1234-5678-90ab-cdef12345678", Filename: "first.bin"}, {ID: "deadbead-1234-5678-90ab-cdef12345678", Filename: "second.bin"}, } for _, entry := range entries { - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { t.Fatalf("failed to seed db entry %s: %v", entry.ID, err) } } @@ -720,7 +697,7 @@ func TestPrintDownloads_FromDatabase_TableAndJSON(t *testing.T) { setupIsolatedCmdState(t) removeActivePort() - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: "12345678-1234-1234-1234-1234567890ab", URL: "https://example.com/asset.bin", Filename: "this-is-a-very-long-file-name-that-should-truncate.bin", @@ -728,7 +705,7 @@ func TestPrintDownloads_FromDatabase_TableAndJSON(t *testing.T) { Downloaded: 512, TotalSize: 1024, } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { t.Fatalf("failed to seed db entry: %v", err) } @@ -789,12 +766,12 @@ func TestPrintDownloads_JSONEmpty(t *testing.T) { func TestPrintDownloads_StrictRemoteEmpty_DoesNotFallbackToDB(t *testing.T) { setupIsolatedCmdState(t) - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: "feedface-1234-5678-90ab-cdef12345678", Filename: "local-only.bin", Status: "completed", } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { t.Fatalf("failed to seed local db entry: %v", err) } @@ -822,7 +799,7 @@ func TestShowDownloadDetails_UsesDatabaseFallback(t *testing.T) { setupIsolatedCmdState(t) removeActivePort() - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: "87654321-1234-1234-1234-1234567890ab", URL: "https://example.com/detail.bin", Filename: "detail.bin", @@ -830,7 +807,7 @@ func TestShowDownloadDetails_UsesDatabaseFallback(t *testing.T) { Downloaded: 250, TotalSize: 500, } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { t.Fatalf("failed to seed db entry: %v", err) } @@ -865,9 +842,9 @@ func TestSendToServer_SuccessAndServerError(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ln, err := net.Listen("tcp", "127.0.0.1:0") + ln, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { - t.Fatalf("listen failed: %v", err) + t.Skipf("tcp4 listener unavailable: %v", err) } defer func() { _ = ln.Close() }() @@ -905,9 +882,9 @@ func TestSendToServer_SuccessAndServerError(t *testing.T) { func TestSendToServer_UsesBearerTokenFromEnv(t *testing.T) { t.Setenv("SURGE_TOKEN", "env-token-123") - ln, err := net.Listen("tcp", "127.0.0.1:0") + ln, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { - t.Fatalf("listen failed: %v", err) + t.Skipf("tcp4 listener unavailable: %v", err) } defer func() { _ = ln.Close() }() @@ -935,9 +912,9 @@ func TestSendToServer_UsesBearerTokenFromEnv(t *testing.T) { func TestGetRemoteDownloads_UsesBearerTokenFromEnv(t *testing.T) { t.Setenv("SURGE_TOKEN", "env-token-123") - ln, err := net.Listen("tcp", "127.0.0.1:0") + ln, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { - t.Fatalf("listen failed: %v", err) + t.Skipf("tcp4 listener unavailable: %v", err) } defer func() { _ = ln.Close() }() @@ -1002,9 +979,9 @@ func TestGetRemoteDownloads_NonOKAndInvalidJSON(t *testing.T) { func TestProcessDownloads_RemoteAndLocal(t *testing.T) { t.Run("remote-mode", func(t *testing.T) { - ln, err := net.Listen("tcp", "127.0.0.1:0") + ln, err := net.Listen("tcp4", "127.0.0.1:0") if err != nil { - t.Fatalf("listen failed: %v", err) + t.Skipf("tcp4 listener unavailable: %v", err) } defer func() { _ = ln.Close() }() @@ -1038,9 +1015,12 @@ func TestProcessDownloads_RemoteAndLocal(t *testing.T) { setupIsolatedCmdState(t) atomic.StoreInt32(&activeDownloads, 0) - GlobalProgressCh = make(chan any, 10) - GlobalPool = download.NewWorkerPool(GlobalProgressCh, 2) - GlobalService = core.NewLocalDownloadService(GlobalPool) + GlobalProgressCh = make(chan types.DownloadEvent, 10) + GlobalPool = scheduler.New(GlobalProgressCh, 2) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + GlobalService = service.NewLocalDownloadService(GlobalLifecycle) probeServer := testutil.NewHTTPServerT(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", "5") @@ -1079,8 +1059,8 @@ func setupIsolatedCmdState(t *testing.T) { t.Fatalf("EnsureDirs failed: %v", err) } - state.CloseDB() - state.Configure(filepath.Join(config.GetStateDir(), "surge.db")) + store.CloseDB() + store.Configure(filepath.Join(config.GetStateDir(), "surge.db")) } func resetCommandConnectionState(t *testing.T) { diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index eb6b1c870..5c044fb99 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -1,6 +1,8 @@ package cmd import ( + "github.com/SurgeDM/Surge/internal/types" + "bytes" "encoding/json" "fmt" @@ -15,16 +17,16 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" "github.com/SurgeDM/Surge/internal/testutil" "github.com/spf13/cobra" ) func init() { // Initialize GlobalPool for tests - GlobalProgressCh = make(chan any, 100) - GlobalPool = download.NewWorkerPool(GlobalProgressCh, 4) + GlobalProgressCh = make(chan types.DownloadEvent, 100) + GlobalPool = scheduler.New(GlobalProgressCh, 4) } // ============================================================================= @@ -412,7 +414,7 @@ func TestHandleDownload_MethodNotAllowed(t *testing.T) { req := httptest.NewRequest(http.MethodPut, "/download", nil) rec := httptest.NewRecorder() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) if rec.Code != http.StatusMethodNotAllowed { @@ -424,7 +426,7 @@ func TestHandleDownload_InvalidJSON(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/download", bytes.NewBufferString("not json")) rec := httptest.NewRecorder() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) if rec.Code != http.StatusBadRequest { @@ -440,7 +442,7 @@ func TestHandleDownload_MissingURL(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/download", bytes.NewBufferString(body)) rec := httptest.NewRecorder() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) if rec.Code != http.StatusBadRequest { @@ -456,7 +458,7 @@ func TestHandleDownload_EmptyURL(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/download", bytes.NewBufferString(body)) rec := httptest.NewRecorder() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) if rec.Code != http.StatusBadRequest { @@ -480,7 +482,7 @@ func TestHandleDownload_PathTraversal(t *testing.T) { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/download", bytes.NewBufferString(tt.body)) rec := httptest.NewRecorder() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) if rec.Code != http.StatusBadRequest { @@ -493,9 +495,9 @@ func TestHandleDownload_PathTraversal(t *testing.T) { // func TestHandleDownload_StatusQuery(t *testing.T) { // // Setup mock download // id := "test-status-id" -// state := types.NewProgressState(id, 2000) +// state := progress.New(id, 2000) // state.Downloaded.Store(1000) -// GlobalPool.Add(types.DownloadConfig{ +// GlobalPool.Add(types.DownloadRecord{ // ID: id, // URL: "http://example.com/test", // State: state, @@ -532,7 +534,7 @@ func TestHandleDownload_StatusQuery_NotFound(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/download?id=missing-id", nil) rec := httptest.NewRecorder() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) if rec.Code != http.StatusNotFound { @@ -919,7 +921,7 @@ func TestStartHTTPServer_HealthEndpoint(t *testing.T) { port := ln.Addr().(*net.TCPAddr).Port // Start server in background - svc := core.NewLocalDownloadService(nil) // Mock service with nil pool/chan for health check + svc := service.NewLocalDownloadService(nil) // Mock service with nil pool/chan for health check go startHTTPServer(ln, port, "", svc, "") // Give server time to start @@ -957,7 +959,7 @@ func TestStartHTTPServer_HasCORSHeaders(t *testing.T) { } port := ln.Addr().(*net.TCPAddr).Port - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) go startHTTPServer(ln, port, "", svc, "") time.Sleep(50 * time.Millisecond) @@ -980,7 +982,7 @@ func TestStartHTTPServer_OptionsRequest(t *testing.T) { } port := ln.Addr().(*net.TCPAddr).Port - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) go startHTTPServer(ln, port, "", svc, "") time.Sleep(50 * time.Millisecond) @@ -1005,7 +1007,7 @@ func TestStartHTTPServer_DownloadEndpoint_MethodNotAllowed(t *testing.T) { } port := ln.Addr().(*net.TCPAddr).Port - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) go startHTTPServer(ln, port, "", svc, "") time.Sleep(50 * time.Millisecond) @@ -1033,7 +1035,7 @@ func TestStartHTTPServer_DownloadEndpoint_BadRequest(t *testing.T) { } port := ln.Addr().(*net.TCPAddr).Port - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) go startHTTPServer(ln, port, "", svc, "") time.Sleep(50 * time.Millisecond) @@ -1061,7 +1063,7 @@ func TestStartHTTPServer_DownloadEndpoint_MissingURL(t *testing.T) { } port := ln.Addr().(*net.TCPAddr).Port - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) go startHTTPServer(ln, port, "", svc, "") time.Sleep(50 * time.Millisecond) @@ -1089,7 +1091,7 @@ func TestStartHTTPServer_NotFoundEndpoint(t *testing.T) { } port := ln.Addr().(*net.TCPAddr).Port - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) go startHTTPServer(ln, port, "", svc, "") time.Sleep(50 * time.Millisecond) @@ -1130,7 +1132,7 @@ func TestHandleDownload_ValidRequest_NoServerProgram(t *testing.T) { } }() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) } @@ -1138,7 +1140,7 @@ func TestHandleDownload_EmptyBody(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/download", bytes.NewBufferString("")) rec := httptest.NewRecorder() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) // Empty body causes EOF error on decode @@ -1155,7 +1157,7 @@ func TestHandleDownload_LargeURL(t *testing.T) { rec := httptest.NewRecorder() // This should handle large URLs gracefully (validation issues) - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) // Should fail on URL validation or JSON parsing @@ -1173,7 +1175,7 @@ func TestHandleDownload_SpecialCharactersInPath(t *testing.T) { } }() - svc := core.NewLocalDownloadService(nil) + svc := service.NewLocalDownloadService(nil) handleDownload(rec, req, "", svc) } diff --git a/cmd/connect.go b/cmd/connect.go index cbec02a64..040a1031f 100644 --- a/cmd/connect.go +++ b/cmd/connect.go @@ -10,7 +10,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" - "github.com/SurgeDM/Surge/internal/core" + "github.com/SurgeDM/Surge/internal/service" "github.com/SurgeDM/Surge/internal/tui" "github.com/spf13/cobra" ) @@ -99,7 +99,7 @@ func connectAndRunTUI(_ *cobra.Command, target string) error { return nil } -func newRemoteRootModel(baseURL string, service core.DownloadService) tui.RootModel { +func newRemoteRootModel(baseURL string, service service.DownloadService) tui.RootModel { serverHost, serverPort := parseRemoteServerAddress(baseURL) m := tui.InitialRootModel(serverPort, Version, service, nil, false, Commit) m.ServerHost = serverHost diff --git a/cmd/connect_test.go b/cmd/connect_test.go index e927dab6e..d3a238c7f 100644 --- a/cmd/connect_test.go +++ b/cmd/connect_test.go @@ -4,10 +4,9 @@ import ( "context" "testing" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/service" "github.com/SurgeDM/Surge/internal/tui" + "github.com/SurgeDM/Surge/internal/types" ) type fakeRemoteDownloadService struct { @@ -18,17 +17,17 @@ type fakeRemoteDownloadService struct { lastExplicit bool } -var _ core.DownloadService = (*fakeRemoteDownloadService)(nil) +var _ service.DownloadService = (*fakeRemoteDownloadService)(nil) func (f *fakeRemoteDownloadService) List() ([]types.DownloadStatus, error) { return nil, nil } -func (f *fakeRemoteDownloadService) History() ([]types.DownloadEntry, error) { +func (f *fakeRemoteDownloadService) History() ([]types.DownloadRecord, error) { return nil, nil } -func (f *fakeRemoteDownloadService) Add(url, path, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { +func (f *fakeRemoteDownloadService) Add(url, path, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64) (string, error) { f.addCalls++ f.lastURL = url f.lastPath = path @@ -37,7 +36,7 @@ func (f *fakeRemoteDownloadService) Add(url, path, filename string, mirrors []st return "remote-add-id", nil } -func (f *fakeRemoteDownloadService) AddWithID(url, path, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { +func (f *fakeRemoteDownloadService) AddWithID(url, path, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) { return id, nil } @@ -53,12 +52,12 @@ func (f *fakeRemoteDownloadService) Delete(id string) error { return nil } func (f *fakeRemoteDownloadService) Purge(id string) error { return nil } -func (f *fakeRemoteDownloadService) StreamEvents(ctx context.Context) (<-chan interface{}, func(), error) { - ch := make(chan interface{}) +func (f *fakeRemoteDownloadService) StreamEvents(ctx context.Context) (<-chan types.DownloadEvent, func(), error) { + ch := make(chan types.DownloadEvent) return ch, func() { close(ch) }, nil } -func (f *fakeRemoteDownloadService) Publish(msg interface{}) error { return nil } +func (f *fakeRemoteDownloadService) Publish(msg types.DownloadEvent) error { return nil } func (f *fakeRemoteDownloadService) GetStatus(id string) (*types.DownloadStatus, error) { return nil, nil @@ -96,7 +95,8 @@ func TestNewRemoteRootModel_DownloadRequestUsesServiceAdd(t *testing.T) { m.Settings.Extension.ExtensionPrompt.Value = false m.Settings.General.WarnOnDuplicate.Value = false - updated, cmd := m.Update(events.DownloadRequestMsg{ + updated, cmd := m.Update(types.DownloadEvent{ + Type: types.EventRequest, URL: "https://example.com/file.bin", Filename: "file.bin", Path: ".", diff --git a/cmd/get_test.go b/cmd/get_test.go index 54abfaedc..fe2dfe8ae 100644 --- a/cmd/get_test.go +++ b/cmd/get_test.go @@ -4,27 +4,27 @@ import ( "context" "encoding/json" "net/http" - "net/http/httptest" "os" "path/filepath" "testing" "time" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) -func startAuthedTestServer(t *testing.T, service core.DownloadService, token string) string { +func startAuthedTestServer(t *testing.T, service service.DownloadService, token string) string { t.Helper() mux := http.NewServeMux() registerHTTPRoutes(mux, 0, "", service) handler := corsMiddleware(authMiddleware(token, mux)) - server := httptest.NewServer(handler) + server := testutil.NewHTTPServerT(t, handler) t.Cleanup(server.Close) return server.URL } @@ -32,19 +32,20 @@ func startAuthedTestServer(t *testing.T, service core.DownloadService, token str func TestCLI_DeleteEndpoint_CleansPausedStateAndPartialFile(t *testing.T) { tempDir := setupXDGEnvIsolation(t) - state.CloseDB() + store.CloseDB() if err := initializeGlobalState(); err != nil { t.Fatalf("initializeGlobalState failed: %v", err) } - GlobalProgressCh = make(chan any, 100) - GlobalPool = download.NewWorkerPool(GlobalProgressCh, 2) + GlobalProgressCh = make(chan types.DownloadEvent, 100) + GlobalPool = scheduler.New(GlobalProgressCh, 2) // Start server - svc := core.NewLocalDownloadService(GlobalPool) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + lifecycle := orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + svc := service.NewLocalDownloadService(lifecycle) t.Cleanup(func() { _ = svc.Shutdown() }) - - lifecycle := processing.NewLifecycleManager(nil, nil) stream, streamCleanup, err := svc.StreamEvents(context.Background()) if err != nil { t.Fatalf("failed to open event stream: %v", err) @@ -85,7 +86,7 @@ func TestCLI_DeleteEndpoint_CleansPausedStateAndPartialFile(t *testing.T) { t.Fatalf("failed to create partial file: %v", err) } - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: id, URL: url, DestPath: destPath, @@ -97,7 +98,7 @@ func TestCLI_DeleteEndpoint_CleansPausedStateAndPartialFile(t *testing.T) { t.Fatalf("failed to seed master list: %v", err) } - if err := state.SaveState(url, destPath, &types.DownloadState{ + if err := store.SaveState(url, destPath, &types.DownloadRecord{ ID: id, URL: url, DestPath: destPath, @@ -132,7 +133,7 @@ func TestCLI_DeleteEndpoint_CleansPausedStateAndPartialFile(t *testing.T) { deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { _, statErr := os.Stat(incompletePath) - entry, dbErr := state.GetDownload(id) + entry, dbErr := store.GetDownload(id) if dbErr != nil { t.Fatalf("failed to query entry after delete: %v", dbErr) } @@ -145,7 +146,7 @@ func TestCLI_DeleteEndpoint_CleansPausedStateAndPartialFile(t *testing.T) { if _, err := os.Stat(incompletePath); !os.IsNotExist(err) { t.Fatalf("expected partial file to be deleted, stat err: %v", err) } - entry, err := state.GetDownload(id) + entry, err := store.GetDownload(id) if err != nil { t.Fatalf("failed to query entry after delete: %v", err) } diff --git a/cmd/headless_approval_test.go b/cmd/headless_approval_test.go index 311407407..ba8ecd22c 100644 --- a/cmd/headless_approval_test.go +++ b/cmd/headless_approval_test.go @@ -8,11 +8,12 @@ import ( "testing" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) func TestHandleDownload_HeadlessMode_AutoApprovesNonDuplicate(t *testing.T) { @@ -42,23 +43,22 @@ func TestHandleDownload_HeadlessMode_AutoApprovesNonDuplicate(t *testing.T) { } // Mock server for probe - probeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + probeServer := testutil.NewHTTPServerT(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Length", "1024") w.WriteHeader(http.StatusOK) _, _ = w.Write(make([]byte, 10)) })) defer probeServer.Close() - progressCh := make(chan any, 10) + progressCh := make(chan types.DownloadEvent, 10) GlobalProgressCh = progressCh - GlobalPool = download.NewWorkerPool(progressCh, 1) + GlobalPool = scheduler.New(progressCh, 1) - // Mock lifecycle to bypass real downloads - GlobalLifecycle = processing.NewLifecycleManager(func(url, path, filename string, _ []string, headers map[string]string, explicit bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - return "queued-id", nil - }, nil) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) - svc := core.NewLocalDownloadService(GlobalPool) + svc := service.NewLocalDownloadService(GlobalLifecycle) GlobalService = svc // Verify it auto-approves even with ExtensionPrompt=true @@ -99,20 +99,23 @@ func TestHandleDownload_HeadlessMode_RejectsDuplicateWithWarn(t *testing.T) { t.Fatalf("SaveSettings failed: %v", err) } - progressCh := make(chan any, 10) + progressCh := make(chan types.DownloadEvent, 10) GlobalProgressCh = progressCh - GlobalPool = download.NewWorkerPool(progressCh, 1) + GlobalPool = scheduler.New(progressCh, 1) // Seed the DB with a "duplicate" entry url := "http://example.com/duplicate.bin" - _ = state.AddToMasterList(types.DownloadEntry{ + _ = store.AddToMasterList(types.DownloadRecord{ ID: "dup-id", URL: url, Filename: "duplicate.bin", Status: "completed", }) - svc := core.NewLocalDownloadService(GlobalPool) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + svc := service.NewLocalDownloadService(GlobalLifecycle) GlobalService = svc // Verify it still rejects duplicates when WarnOnDuplicate is on @@ -152,14 +155,17 @@ func TestHandleDownload_HeadlessMode_RejectsExtensionPromptDuplicate(t *testing. } url := "http://example.com/already-downloaded.bin" - _ = state.AddToMasterList(types.DownloadEntry{ + _ = store.AddToMasterList(types.DownloadRecord{ ID: "ext-dup-id", URL: url, Filename: "already-downloaded.bin", Status: "completed", }) - progressCh := make(chan any, 10) + progressCh := make(chan types.DownloadEvent, 10) GlobalProgressCh = progressCh - GlobalPool = download.NewWorkerPool(progressCh, 1) - svc := core.NewLocalDownloadService(GlobalPool) + GlobalPool = scheduler.New(progressCh, 1) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + svc := service.NewLocalDownloadService(GlobalLifecycle) GlobalService = svc body := fmt.Sprintf(`{"url": %q, "skip_approval": false}`, url) diff --git a/cmd/http_api.go b/cmd/http_api.go index c8278fc20..809ffd782 100644 --- a/cmd/http_api.go +++ b/cmd/http_api.go @@ -12,9 +12,8 @@ import ( "strings" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -29,7 +28,7 @@ type rateLimitSettingsService interface { SetDefaultRateLimit(rate int64) error } -func registerHTTPRoutes(mux *http.ServeMux, port int, defaultOutputDir string, service core.DownloadService) { +func registerHTTPRoutes(mux *http.ServeMux, port int, defaultOutputDir string, service service.DownloadService) { mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { writeJSONResponse(w, http.StatusOK, map[string]interface{}{ "status": "ok", @@ -291,7 +290,7 @@ func parseRateLimitQuery(w http.ResponseWriter, r *http.Request) (int64, string, return rate, rateStr, true } -func eventsHandler(service core.DownloadService) http.HandlerFunc { +func eventsHandler(service service.DownloadService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -322,7 +321,7 @@ func eventsHandler(service core.DownloadService) http.HandlerFunc { return } - frames, err := events.EncodeSSEMessages(msg) + frames, err := types.EncodeSSEMessages(msg) if err != nil { utils.Debug("Error encoding SSE event: %v", err) continue @@ -379,7 +378,7 @@ func writeJSONResponse(w http.ResponseWriter, status int, payload interface{}) { } } -func resolveDownloadDestPath(service core.DownloadService, id string) (string, error) { +func resolveDownloadDestPath(service service.DownloadService, id string) (string, error) { if service == nil { return "", ErrServiceUnavailable } diff --git a/cmd/http_api_test.go b/cmd/http_api_test.go index eb175716e..f25cb6a4b 100644 --- a/cmd/http_api_test.go +++ b/cmd/http_api_test.go @@ -12,17 +12,17 @@ import ( tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) type httpAPITestService struct { - history []types.DownloadEntry + history []types.DownloadRecord historyErr error statusByID map[string]*types.DownloadStatus getStatusErr error - streamMsgs []interface{} + streamMsgs []types.DownloadEvent rateLimitCalls []string rateLimitValues map[string]int64 clearRateLimitID []string @@ -45,18 +45,18 @@ func (s *httpAPITestService) List() ([]types.DownloadStatus, error) { return nil, nil } -func (s *httpAPITestService) History() ([]types.DownloadEntry, error) { +func (s *httpAPITestService) History() ([]types.DownloadRecord, error) { if s.historyErr != nil { return nil, s.historyErr } return s.history, nil } -func (s *httpAPITestService) Add(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { +func (s *httpAPITestService) Add(string, string, string, []string, map[string]string, bool, int, int64) (string, error) { return "", errors.New("not implemented") } -func (s *httpAPITestService) AddWithID(string, string, string, []string, map[string]string, string, int, int64, int64, bool) (string, error) { +func (s *httpAPITestService) AddWithID(string, string, string, []string, map[string]string, string, int, int64) (string, error) { return "", errors.New("not implemented") } @@ -83,8 +83,8 @@ func (s *httpAPITestService) Purge(string) error { return nil } -func (s *httpAPITestService) StreamEvents(context.Context) (<-chan interface{}, func(), error) { - channel := make(chan interface{}, len(s.streamMsgs)) +func (s *httpAPITestService) StreamEvents(context.Context) (<-chan types.DownloadEvent, func(), error) { + channel := make(chan types.DownloadEvent, len(s.streamMsgs)) for _, msg := range s.streamMsgs { channel <- msg } @@ -93,16 +93,16 @@ func (s *httpAPITestService) StreamEvents(context.Context) (<-chan interface{}, return channel, cleanup, nil } -func (s *httpAPITestService) Publish(interface{}) error { +func (s *httpAPITestService) Publish(types.DownloadEvent) error { return nil } type publishRecordingHTTPService struct { *httpAPITestService - published []interface{} + published []types.DownloadEvent } -func (s *publishRecordingHTTPService) Publish(msg interface{}) error { +func (s *publishRecordingHTTPService) Publish(msg types.DownloadEvent) error { s.published = append(s.published, msg) return nil } @@ -113,7 +113,7 @@ type batchAddRecordingService struct { failOn string } -func (s *batchAddRecordingService) Add(url string, _ string, _ string, _ []string, _ map[string]string, _ bool, _ int, _ int64, _ int64, _ bool) (string, error) { +func (s *batchAddRecordingService) Add(url string, _ string, _ string, _ []string, _ map[string]string, _ bool, _ int, _ int64) (string, error) { if url == s.failOn { return "", errors.New("enqueue failed") } @@ -209,7 +209,7 @@ func TestEnsureOpenActionRequestAllowed_RemoteToggle(t *testing.T) { func TestHistoryEndpoint_SortsMostRecentFirst(t *testing.T) { service := &httpAPITestService{ - history: []types.DownloadEntry{ + history: []types.DownloadRecord{ {ID: "old", CompletedAt: 10}, {ID: "new", CompletedAt: 30}, {ID: "middle", CompletedAt: 20}, @@ -227,7 +227,7 @@ func TestHistoryEndpoint_SortsMostRecentFirst(t *testing.T) { t.Fatalf("expected status 200, got %d", recorder.Code) } - var got []types.DownloadEntry + var got []types.DownloadRecord if err := json.Unmarshal(recorder.Body.Bytes(), &got); err != nil { t.Fatalf("failed to parse response: %v", err) } @@ -243,8 +243,9 @@ func TestHistoryEndpoint_SortsMostRecentFirst(t *testing.T) { func TestEventsEndpoint_RequiresAuthAndStreamsSSE(t *testing.T) { service := &httpAPITestService{ - streamMsgs: []interface{}{ - events.DownloadQueuedMsg{ + streamMsgs: []types.DownloadEvent{ + types.DownloadEvent{ + Type: types.EventQueued, DownloadID: "queue-1", Filename: "archive.zip", URL: "https://example.com/archive.zip", @@ -256,7 +257,7 @@ func TestEventsEndpoint_RequiresAuthAndStreamsSSE(t *testing.T) { mux := http.NewServeMux() registerHTTPRoutes(mux, 0, "", service) handler := corsMiddleware(authMiddleware("test-token", mux)) - server := httptest.NewServer(handler) + server := testutil.NewHTTPServerT(t, handler) defer server.Close() noAuthResp, err := server.Client().Get(server.URL + "/events") @@ -295,7 +296,7 @@ func TestEventsEndpoint_RequiresAuthAndStreamsSSE(t *testing.T) { if !strings.Contains(text, "event: queued") { t.Fatalf("expected queued SSE event, got %q", text) } - if !strings.Contains(text, `"DownloadID":"queue-1"`) { + if !strings.Contains(text, `"download_id":"queue-1"`) { t.Fatalf("expected queued payload in SSE body, got %q", text) } } @@ -329,15 +330,12 @@ func TestHandleBatchDownload_ConfirmPublishesSingleBatchRequest(t *testing.T) { if len(service.published) != 1 { t.Fatalf("expected 1 published message, got %d", len(service.published)) } - msg, ok := service.published[0].(events.BatchDownloadRequestMsg) - if !ok { - t.Fatalf("expected BatchDownloadRequestMsg, got %T", service.published[0]) - } - if len(msg.Requests) != 2 { - t.Fatalf("expected 2 batch requests, got %d", len(msg.Requests)) + msg := service.published[0] + if len(msg.BatchEvents) != 2 { + t.Fatalf("expected 2 batch requests, got %d", len(msg.BatchEvents)) } - if msg.Requests[0].URL != "https://example.com/one.zip" || msg.Requests[1].URL != "https://example.com/two.zip" { - t.Fatalf("unexpected batch URLs: %#v", msg.Requests) + if msg.BatchEvents[0].URL != "https://example.com/one.zip" || msg.BatchEvents[1].URL != "https://example.com/two.zip" { + t.Fatalf("unexpected batch URLs: %#v", msg.BatchEvents) } } @@ -424,7 +422,7 @@ func TestResolveDownloadDestPath(t *testing.T) { statusByID: map[string]*types.DownloadStatus{ "fallback": {ID: "fallback", DestPath: ""}, }, - history: []types.DownloadEntry{{ID: "fallback", DestPath: "C:\\tmp\\b.bin"}}, + history: []types.DownloadRecord{{ID: "fallback", DestPath: "C:\\tmp\\b.bin"}}, }, id: "fallback", wantPath: `C:\tmp\b.bin`, @@ -432,7 +430,7 @@ func TestResolveDownloadDestPath(t *testing.T) { { name: "history entry has no destination path", service: &httpAPITestService{ - history: []types.DownloadEntry{{ID: "bad", DestPath: "."}}, + history: []types.DownloadRecord{{ID: "bad", DestPath: "."}}, }, id: "bad", wantErrIs: ErrNoDestinationPath, @@ -440,7 +438,7 @@ func TestResolveDownloadDestPath(t *testing.T) { { name: "id absent returns not found", service: &httpAPITestService{ - history: []types.DownloadEntry{{ID: "other", DestPath: "C:\\tmp\\c.bin"}}, + history: []types.DownloadRecord{{ID: "other", DestPath: "C:\\tmp\\c.bin"}}, }, id: "missing", wantErrIs: ErrDownloadNotFound, @@ -457,7 +455,7 @@ func TestResolveDownloadDestPath(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - var service core.DownloadService + var service service.DownloadService if !test.useNilService { service = test.service } @@ -511,7 +509,7 @@ func TestOpenEndpoints_ReturnMappedResolveStatuses(t *testing.T) { name: "missing download returns 404", path: "/open-folder?id=missing", service: &httpAPITestService{ - history: []types.DownloadEntry{}, + history: []types.DownloadRecord{}, }, statusCode: http.StatusNotFound, }, @@ -528,7 +526,7 @@ func TestOpenEndpoints_ReturnMappedResolveStatuses(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { mux := http.NewServeMux() - var service core.DownloadService + var service service.DownloadService if !test.useNil { service = test.service } @@ -619,7 +617,7 @@ func TestExecuteAPIAction_SendsIDAsQueryParam(t *testing.T) { rec := &recordingActionService{httpAPITestService: &httpAPITestService{}, ids: map[string]string{}} mux := http.NewServeMux() registerHTTPRoutes(mux, 0, "", rec) - server := httptest.NewServer(mux) + server := testutil.NewHTTPServerT(t, mux) defer server.Close() prevHost, prevToken := globalHost, globalToken @@ -843,12 +841,12 @@ type rateLimitWrapper struct { svc *httpAPITestService } -func (r *rateLimitWrapper) List() ([]types.DownloadStatus, error) { return nil, nil } -func (r *rateLimitWrapper) History() ([]types.DownloadEntry, error) { return nil, nil } -func (r *rateLimitWrapper) Add(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { +func (r *rateLimitWrapper) List() ([]types.DownloadStatus, error) { return nil, nil } +func (r *rateLimitWrapper) History() ([]types.DownloadRecord, error) { return nil, nil } +func (r *rateLimitWrapper) Add(string, string, string, []string, map[string]string, bool, int, int64) (string, error) { return "", nil } -func (r *rateLimitWrapper) AddWithID(string, string, string, []string, map[string]string, string, int, int64, int64, bool) (string, error) { +func (r *rateLimitWrapper) AddWithID(string, string, string, []string, map[string]string, string, int, int64) (string, error) { return "", nil } func (r *rateLimitWrapper) Pause(string) error { return nil } @@ -857,15 +855,15 @@ func (r *rateLimitWrapper) ResumeBatch([]string) []error { re func (r *rateLimitWrapper) UpdateURL(string, string) error { return nil } func (r *rateLimitWrapper) Delete(string) error { return nil } func (r *rateLimitWrapper) Purge(string) error { return nil } -func (r *rateLimitWrapper) Publish(interface{}) error { return nil } +func (r *rateLimitWrapper) Publish(types.DownloadEvent) error { return nil } func (r *rateLimitWrapper) GetStatus(string) (*types.DownloadStatus, error) { return nil, nil } func (r *rateLimitWrapper) Shutdown() error { return nil } func (r *rateLimitWrapper) ClearCompleted() (int64, error) { return 0, nil } func (r *rateLimitWrapper) ClearFailed() (int64, error) { return 0, nil } func (r *rateLimitWrapper) SetRateLimit(string, int64) error { return nil } func (r *rateLimitWrapper) ClearRateLimit(string) error { return nil } -func (r *rateLimitWrapper) StreamEvents(context.Context) (<-chan interface{}, func(), error) { - return make(chan interface{}), func() {}, nil +func (r *rateLimitWrapper) StreamEvents(context.Context) (<-chan types.DownloadEvent, func(), error) { + return make(chan types.DownloadEvent), func() {}, nil } // TestRateLimitDefaultEndpoint tests the /rate-limit/default endpoint. diff --git a/cmd/http_handler_test.go b/cmd/http_handler_test.go index f645c1c67..fd5249fe5 100644 --- a/cmd/http_handler_test.go +++ b/cmd/http_handler_test.go @@ -9,16 +9,18 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) func TestHandleDownload_PathResolution(t *testing.T) { @@ -46,9 +48,9 @@ func TestHandleDownload_PathResolution(t *testing.T) { t.Cleanup(func() { globalSettings = origSettings }) // Ensure a clean state DB for the test scope. - state.CloseDB() - state.Configure(filepath.Join(tempDir, "surge.db")) - defer state.CloseDB() + store.CloseDB() + store.Configure(filepath.Join(tempDir, "surge.db")) + defer store.CloseDB() // Mock XDG_CONFIG_HOME to affect GetSurgeDir() on Linux originalConfigHome := os.Getenv("XDG_CONFIG_HOME") @@ -83,12 +85,13 @@ func TestHandleDownload_PathResolution(t *testing.T) { } // Initialize GlobalPool (required by handleDownload) - GlobalPool = download.NewWorkerPool(nil, 1) + GlobalPool = scheduler.New(nil, 1) tests := []struct { name string request DownloadRequest expectedOutputPath string + skipOnWindows bool // simulation tests that don't apply when the daemon IS on Windows }{ { name: "Absolute Path (Explicit)", @@ -133,20 +136,27 @@ func TestHandleDownload_PathResolution(t *testing.T) { expectedOutputPath: defaultDownloadDir, }, { + // On a non-Windows daemon, a Windows absolute path from the extension + // is remapped to the daemon's default download directory. + // On Windows, C:/ is a real local path — no remapping occurs. name: "Windows Download Root Maps To Default Dir", request: DownloadRequest{ URL: "http://example.com/file6", Path: "C:/Users/me/Downloads", }, expectedOutputPath: defaultDownloadDir, + skipOnWindows: true, }, { + // Same as above: the "/surge-repro" suffix is preserved on non-Windows daemons. + // On Windows, this is just a literal local path. name: "Windows Nested Path Maps Under Default Dir", request: DownloadRequest{ URL: "http://example.com/file7", Path: "C:/Users/me/Downloads/surge-repro", }, expectedOutputPath: filepath.Join(defaultDownloadDir, "surge-repro"), + skipOnWindows: true, }, { name: "Windows Nested Path Relative Flag Maps Under Default Dir", @@ -156,6 +166,7 @@ func TestHandleDownload_PathResolution(t *testing.T) { RelativeToDefaultDir: true, }, expectedOutputPath: filepath.Join(defaultDownloadDir, "surge-repro"), + skipOnWindows: true, }, { name: "Unmatched Windows Path Falls Back To Default Dir", @@ -165,15 +176,22 @@ func TestHandleDownload_PathResolution(t *testing.T) { RelativeToDefaultDir: true, }, expectedOutputPath: defaultDownloadDir, + skipOnWindows: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if tt.skipOnWindows && runtime.GOOS == "windows" { + t.Skip("path-remapping simulation does not apply on Windows daemons") + } body, _ := json.Marshal(tt.request) req := httptest.NewRequest("POST", "/download", bytes.NewBuffer(body)) w := httptest.NewRecorder() - svc := core.NewLocalDownloadService(GlobalPool) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + svc := service.NewLocalDownloadService(GlobalLifecycle) // We pass defaultDownloadDir as a fallback to handleDownload, but since we mocked settings, // it should prioritize settings.General.DefaultDownloadDir @@ -191,12 +209,17 @@ func TestHandleDownload_PathResolution(t *testing.T) { found = true t.Logf("OutputPath for %s: %s", tt.name, cfg.OutputPath) - if !filepath.IsAbs(cfg.OutputPath) { - t.Errorf("Expected absolute path, got %s", cfg.OutputPath) + expectedAbs := tt.expectedOutputPath + if abs, err := filepath.Abs(expectedAbs); err == nil { + expectedAbs = abs } - if cfg.OutputPath != tt.expectedOutputPath { - t.Errorf("Expected path %s, got %s", tt.expectedOutputPath, cfg.OutputPath) + if cfg.OutputPath != expectedAbs { + if runtime.GOOS == "windows" && strings.EqualFold(cfg.OutputPath, expectedAbs) { + // Windows paths are case-insensitive + } else { + t.Errorf("Expected path %s, got %s", expectedAbs, cfg.OutputPath) + } } break } @@ -262,9 +285,9 @@ func mustGetwd(t *testing.T) string { func TestHandleDownload_SkipApprovalUsesLifecycleEnqueue(t *testing.T) { setupIsolatedCmdState(t) - progressCh := make(chan any, 10) + progressCh := make(chan types.DownloadEvent, 10) GlobalProgressCh = progressCh - GlobalPool = download.NewWorkerPool(progressCh, 1) + GlobalPool = scheduler.New(progressCh, 1) origLifecycle := GlobalLifecycle origService := GlobalService @@ -275,7 +298,7 @@ func TestHandleDownload_SkipApprovalUsesLifecycleEnqueue(t *testing.T) { GlobalProgressCh = nil }) - probeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + probeServer := testutil.NewHTTPServerT(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Range"); got != "bytes=0-0" { t.Fatalf("Range header = %q, want bytes=0-0", got) } @@ -289,40 +312,10 @@ func TestHandleDownload_SkipApprovalUsesLifecycleEnqueue(t *testing.T) { tempDir := t.TempDir() expectedFile := "from-extension.bin" - var addCalls int - GlobalLifecycle = processing.NewLifecycleManager(func(url, path, filename string, _ []string, headers map[string]string, explicit bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - addCalls++ - if url != probeServer.URL { - t.Fatalf("url = %q, want %q", url, probeServer.URL) - } - if path != tempDir { - t.Fatalf("path = %q, want %q", path, tempDir) - } - if filename != expectedFile { - t.Fatalf("filename = %q, want %q", filename, expectedFile) - } - if !explicit { - t.Fatal("expected explicit category flag to be preserved") - } - if totalSize != 7 { - t.Fatalf("totalSize = %d, want 7", totalSize) - } - if !supportsRange { - t.Fatal("expected probe to preserve range support") - } - if headers["Authorization"] != "Bearer test" { - t.Fatalf("headers were not forwarded to lifecycle addFunc") - } - - surgePath := filepath.Join(path, filename) + types.IncompleteSuffix - if _, err := os.Stat(surgePath); err != nil { - t.Fatalf("expected pre-created working file before addFunc: %v", err) - } - - return "queued-id", nil - }, nil) - - svc := core.NewLocalDownloadService(nil) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + svc := service.NewLocalDownloadService(GlobalLifecycle) GlobalService = svc t.Cleanup(func() { _ = svc.Shutdown() @@ -345,25 +338,37 @@ func TestHandleDownload_SkipApprovalUsesLifecycleEnqueue(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } - if addCalls != 1 { - t.Fatalf("expected lifecycle addFunc to be called once, got %d", addCalls) + configs := GlobalPool.GetAll() + if len(configs) != 1 { + t.Fatalf("expected 1 download queued, got %d", len(configs)) } - - var resp map[string]string - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to decode response: %v", err) + cfg := configs[0] + if cfg.URL != probeServer.URL { + t.Fatalf("url = %q, want %q", cfg.URL, probeServer.URL) + } + if cfg.OutputPath != tempDir { + t.Fatalf("path = %q, want %q", cfg.OutputPath, tempDir) + } + if cfg.Filename != expectedFile { + t.Fatalf("filename = %q, want %q", cfg.Filename, expectedFile) } - if resp["id"] != "queued-id" { - t.Fatalf("response id = %q, want queued-id", resp["id"]) + if cfg.TotalSize != 7 { + t.Fatalf("totalSize = %d, want 7", cfg.TotalSize) + } + if !cfg.SupportsRange { + t.Fatal("expected probe to preserve range support") + } + if cfg.Headers["Authorization"] != "Bearer test" { + t.Fatalf("headers were not forwarded to lifecycle addFunc") } } func TestHandleDownload_EnqueueError_RecordsPreflightError(t *testing.T) { setupIsolatedCmdState(t) - progressCh := make(chan any, 10) + progressCh := make(chan types.DownloadEvent, 10) GlobalProgressCh = progressCh - GlobalPool = download.NewWorkerPool(progressCh, 1) + GlobalPool = scheduler.New(progressCh, 1) origLifecycle := GlobalLifecycle origService := GlobalService @@ -374,14 +379,10 @@ func TestHandleDownload_EnqueueError_RecordsPreflightError(t *testing.T) { GlobalProgressCh = nil }) - // Create a lifecycle manager whose addFunc should never be reached - // because the probe will fail first (invalid URL scheme). - GlobalLifecycle = processing.NewLifecycleManager(func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { - t.Fatal("addFunc should not be called when probe fails") - return "", nil - }, nil) - - svc := core.NewLocalDownloadService(nil) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + svc := service.NewLocalDownloadService(GlobalLifecycle) GlobalService = svc t.Cleanup(func() { _ = svc.Shutdown() @@ -399,7 +400,7 @@ func TestHandleDownload_EnqueueError_RecordsPreflightError(t *testing.T) { } // Verify that the error was persisted in the master list. - list, err := state.LoadMasterList() + list, err := store.LoadMasterList() if err != nil { t.Fatalf("LoadMasterList failed: %v", err) } @@ -419,9 +420,9 @@ func TestHandleDownload_EnqueueError_RecordsPreflightError(t *testing.T) { func TestHandleDownload_ForwardsPerTaskOverridesToLifecycle(t *testing.T) { setupIsolatedCmdState(t) - progressCh := make(chan any, 10) + progressCh := make(chan types.DownloadEvent, 10) GlobalProgressCh = progressCh - GlobalPool = download.NewWorkerPool(progressCh, 1) + GlobalPool = scheduler.New(progressCh, 1) origLifecycle := GlobalLifecycle origService := GlobalService @@ -432,7 +433,7 @@ func TestHandleDownload_ForwardsPerTaskOverridesToLifecycle(t *testing.T) { GlobalProgressCh = nil }) - probeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + probeServer := testutil.NewHTTPServerT(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Range", "bytes 0-0/1024") w.Header().Set("Content-Length", "1") w.WriteHeader(http.StatusPartialContent) @@ -442,15 +443,10 @@ func TestHandleDownload_ForwardsPerTaskOverridesToLifecycle(t *testing.T) { tempDir := t.TempDir() - var gotWorkers int - var gotMinChunkSize int64 - GlobalLifecycle = processing.NewLifecycleManager(func(_, _, _ string, _ []string, _ map[string]string, _ bool, workers int, minChunkSize int64, _ int64, _ bool) (string, error) { - gotWorkers = workers - gotMinChunkSize = minChunkSize - return "override-id", nil - }, nil) - - svc := core.NewLocalDownloadService(nil) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + svc := service.NewLocalDownloadService(GlobalLifecycle) GlobalService = svc t.Cleanup(func() { _ = svc.Shutdown() @@ -474,11 +470,15 @@ func TestHandleDownload_ForwardsPerTaskOverridesToLifecycle(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } - if gotWorkers != 12 { - t.Fatalf("expected lifecycle addFunc to receive workers=12, got %d", gotWorkers) + configs := GlobalPool.GetAll() + if len(configs) != 1 { + t.Fatalf("expected 1 download queued, got %d", len(configs)) + } + if configs[0].Runtime.Workers != 12 { + t.Fatalf("expected lifecycle addFunc to receive workers=12, got %d", configs[0].Runtime.Workers) } - if gotMinChunkSize != minChunk { - t.Fatalf("expected lifecycle addFunc to receive minChunkSize=%d, got %d", minChunk, gotMinChunkSize) + if configs[0].Runtime.MinChunkSize != minChunk { + t.Fatalf("expected lifecycle addFunc to receive minChunkSize=%d, got %d", minChunk, configs[0].Runtime.MinChunkSize) } } @@ -487,7 +487,7 @@ type failingPublishService struct { publishErr error } -func (f *failingPublishService) Publish(msg interface{}) error { +func (f *failingPublishService) Publish(msg types.DownloadEvent) error { return f.publishErr } @@ -505,7 +505,7 @@ func TestHandleDownload_PublishError_RecordsPreflightError(t *testing.T) { GlobalLifecycle = origLifecycle }) - GlobalPool = download.NewWorkerPool(nil, 1) + GlobalPool = scheduler.New(nil, 1) origServerProgram := serverProgram serverProgram = &tea.Program{} @@ -532,7 +532,7 @@ func TestHandleDownload_PublishError_RecordsPreflightError(t *testing.T) { t.Fatalf("expected 500, got %d: %s", rec.Code, rec.Body.String()) } - list, err := state.LoadMasterList() + list, err := store.LoadMasterList() if err != nil { t.Fatalf("LoadMasterList failed: %v", err) } diff --git a/cmd/ls.go b/cmd/ls.go index fb72fe30f..ff2d475fb 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -9,8 +9,8 @@ import ( "text/tabwriter" "time" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" "github.com/spf13/cobra" ) @@ -94,7 +94,7 @@ func printDownloads(jsonOutput bool, baseURL string, token string, strictRemote // Fall back to database only when not explicitly targeting a remote host. if len(downloads) == 0 && (!strictRemote || baseURL == "") { - dbDownloads, err := state.ListAllDownloads() + dbDownloads, err := store.ListAllDownloads() if err != nil { return fmt.Errorf("error listing downloads: %w", err) } @@ -206,12 +206,12 @@ func showDownloadDetails(partialID string, jsonOutput bool, baseURL string, toke } // Fall back to database - search through all downloads - downloads, err := state.ListAllDownloads() + downloads, err := store.ListAllDownloads() if err != nil { return fmt.Errorf("error listing downloads: %w", err) } - var found *types.DownloadEntry + var found *types.DownloadRecord for _, d := range downloads { if d.ID == fullID { found = &d diff --git a/cmd/main_test.go b/cmd/main_test.go index 007475711..9d51ee1a9 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -7,18 +7,18 @@ import ( "testing" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/state" + "github.com/SurgeDM/Surge/internal/store" "github.com/SurgeDM/Surge/internal/utils" ) func resetSharedStateDB() error { // Reset any pre-existing global DB state (e.g. left by an init or an // isolated test cleanup) before pointing the package at the shared suite DB. - state.CloseDB() + store.CloseDB() if err := config.EnsureDirs(); err != nil { return err } - state.Configure(filepath.Join(config.GetStateDir(), "surge.db")) + store.Configure(filepath.Join(config.GetStateDir(), "surge.db")) return nil } @@ -34,6 +34,7 @@ func TestMain(m *testing.M) { _ = os.Setenv("HOME", tmpDir) _ = os.Setenv("APPDATA", tmpDir) _ = os.Setenv("USERPROFILE", tmpDir) + _ = os.Setenv("SystemRoot", tmpDir) if ensureErr := resetSharedStateDB(); ensureErr != nil { fmt.Fprintf(os.Stderr, "TestMain: failed to create isolated Surge test directories: %v\n", ensureErr) @@ -45,7 +46,7 @@ func TestMain(m *testing.M) { code := m.Run() if err == nil { - state.CloseDB() + store.CloseDB() _ = os.RemoveAll(tmpDir) } os.Exit(code) diff --git a/cmd/remote_client.go b/cmd/remote_client.go index fe05c32d9..271d6fc01 100644 --- a/cmd/remote_client.go +++ b/cmd/remote_client.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "github.com/SurgeDM/Surge/internal/core" + "github.com/SurgeDM/Surge/internal/service" ) const ( @@ -22,14 +22,14 @@ var ( type remoteClientConfig struct { AllowInsecureHTTP bool ConnectTimeout time.Duration - HTTPOptions core.HTTPClientOptions + HTTPOptions service.HTTPClientOptions } func currentRemoteClientConfig() remoteClientConfig { return remoteClientConfig{ AllowInsecureHTTP: globalInsecureHTTP, ConnectTimeout: defaultRemoteConnectTimeout, - HTTPOptions: core.HTTPClientOptions{ + HTTPOptions: service.HTTPClientOptions{ Timeout: defaultRemoteAPIRequestTimeout, InsecureSkipVerify: globalInsecureTLS, CAFile: strings.TrimSpace(globalTLSCAFile), @@ -37,12 +37,12 @@ func currentRemoteClientConfig() remoteClientConfig { } } -func newRemoteDownloadService(baseURL, token string) (*core.RemoteDownloadService, error) { +func newRemoteDownloadService(baseURL, token string) (*service.RemoteDownloadService, error) { cfg := currentRemoteClientConfig() - return core.NewRemoteDownloadService(baseURL, token, cfg.HTTPOptions) + return service.NewRemoteDownloadService(baseURL, token, cfg.HTTPOptions) } func newRemoteAPIHTTPClient() (*http.Client, error) { cfg := currentRemoteClientConfig() - return core.NewHTTPClient(cfg.HTTPOptions) + return service.NewHTTPClient(cfg.HTTPOptions) } diff --git a/cmd/rm.go b/cmd/rm.go index 8535b6f7f..53eb034ea 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -1,10 +1,10 @@ package cmd import ( + "encoding/json" "fmt" "net/http" - "github.com/SurgeDM/Surge/internal/engine/state" "github.com/spf13/cobra" ) @@ -39,19 +39,38 @@ var rmCmd = &cobra.Command{ } if clean { - // Remove completed downloads from DB - count, err := state.RemoveCompletedDownloads() + baseURL, token, err := resolveAPIConnection(true) if err != nil { - return fmt.Errorf("error cleaning downloads: %w", err) + return err } - fmt.Printf("Removed %d completed downloads.\n", count) + resp, err := doAPIRequest(http.MethodPost, baseURL, token, "/clear-completed", nil) + if err != nil { + return fmt.Errorf("failed to send request to server: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server error: %s", resp.Status) + } + var res map[string]int64 + _ = json.NewDecoder(resp.Body).Decode(&res) + fmt.Printf("Removed %d completed downloads.\n", res["deleted"]) return nil } else if cleanFailed { - count, err := state.RemoveFailedDownloads() + baseURL, token, err := resolveAPIConnection(true) if err != nil { - return fmt.Errorf("error cleaning failed downloads: %w", err) + return err + } + resp, err := doAPIRequest(http.MethodPost, baseURL, token, "/clear-failed", nil) + if err != nil { + return fmt.Errorf("failed to send request to server: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server error: %s", resp.Status) } - fmt.Printf("Removed %d failed downloads.\n", count) + var res map[string]int64 + _ = json.NewDecoder(resp.Body).Decode(&res) + fmt.Printf("Removed %d failed downloads.\n", res["deleted"]) return nil } diff --git a/cmd/root.go b/cmd/root.go index 76792a60e..7b82c64ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,13 +15,13 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" "github.com/SurgeDM/Surge/internal/tui" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" tea "charm.land/bubbletea/v2" @@ -73,14 +73,14 @@ var ( // Globals for Unified Backend var ( - GlobalPool *download.WorkerPool - GlobalProgressCh chan any - GlobalService core.DownloadService + GlobalPool *scheduler.Scheduler + GlobalProgressCh chan types.DownloadEvent + GlobalService service.DownloadService GlobalLifecycleCleanup func() serverProgram *tea.Program startupIntegrityMessage string globalSettings *config.Settings - GlobalLifecycle *processing.LifecycleManager + GlobalLifecycle *orchestrator.LifecycleManager globalLifecycleMu sync.Mutex globalEnqueueCtx context.Context globalEnqueueCancel context.CancelFunc @@ -90,7 +90,7 @@ var ( // buildActiveDownloadChecker bridges the lifecycle manager and the worker pool. // LifecycleManager has no direct reference to the pool, so we inject this closure // at construction time to let it detect file-name collisions with in-flight downloads. -func buildActiveDownloadChecker(getAll func() []types.DownloadConfig) processing.IsNameActiveFunc { +func buildActiveDownloadChecker(getAll func() []types.DownloadRecord) orchestrator.IsNameActiveFunc { if getAll == nil { return nil } @@ -111,11 +111,11 @@ func buildActiveDownloadChecker(getAll func() []types.DownloadConfig) processing existingName = filepath.Base(cfg.DestPath) } } - if cfg.State != nil { - if stateName := strings.TrimSpace(cfg.State.GetFilename()); stateName != "" { + if ps := progress.CfgProgress(&cfg); ps != nil { + if stateName := strings.TrimSpace(ps.GetFilename()); stateName != "" { existingName = stateName } - if stateDestPath := strings.TrimSpace(cfg.State.GetDestPath()); stateDestPath != "" { + if stateDestPath := strings.TrimSpace(ps.GetDestPath()); stateDestPath != "" { existingDir = filepath.Dir(stateDestPath) if existingName == "" { existingName = filepath.Base(stateDestPath) @@ -133,18 +133,11 @@ func buildActiveDownloadChecker(getAll func() []types.DownloadConfig) processing } } -func newLocalLifecycleManager(service core.DownloadService, getAll func() []types.DownloadConfig) *processing.LifecycleManager { - var addFunc processing.AddDownloadFunc - var addWithIDFunc processing.AddDownloadWithIDFunc - if service != nil { - addFunc = service.Add - addWithIDFunc = service.AddWithID - } - - return processing.NewLifecycleManager(addFunc, addWithIDFunc, buildActiveDownloadChecker(getAll)) +func newLocalLifecycleManager(pool *scheduler.Scheduler, eventBus *orchestrator.EventBus, getAll func() []types.DownloadRecord) *orchestrator.LifecycleManager { + return orchestrator.NewLifecycleManager(pool, eventBus, buildActiveDownloadChecker(getAll)) } -func startLifecycleEventWorker(service core.DownloadService, mgr *processing.LifecycleManager) (func(), error) { +func startLifecycleEventWorker(service service.DownloadService, mgr *orchestrator.LifecycleManager) (func(), error) { if service == nil || mgr == nil { return nil, nil } @@ -157,7 +150,7 @@ func startLifecycleEventWorker(service core.DownloadService, mgr *processing.Lif return managerCleanup, nil } -func currentLifecycle() *processing.LifecycleManager { +func currentLifecycle() *orchestrator.LifecycleManager { globalLifecycleMu.Lock() defer globalLifecycleMu.Unlock() return GlobalLifecycle @@ -215,14 +208,14 @@ func setLifecycleCleanupForTest(fn func()) { globalLifecycleMu.Unlock() } -func currentPoolConfigs() []types.DownloadConfig { +func currentPoolConfigs() []types.DownloadRecord { if GlobalPool == nil { return nil } return GlobalPool.GetAll() } -func lifecycleForLocalService(service core.DownloadService) (*processing.LifecycleManager, error) { +func lifecycleForLocalService(service service.DownloadService) (*orchestrator.LifecycleManager, error) { lifecycle := currentLifecycle() if service == nil || GlobalService == nil || service != GlobalService { return lifecycle, nil @@ -232,31 +225,18 @@ func lifecycleForLocalService(service core.DownloadService) (*processing.Lifecyc func ensureGlobalLocalServiceAndLifecycle() error { if GlobalService == nil { - localService := core.NewLocalDownloadServiceWithInput(GlobalPool, GlobalProgressCh) + eventBus := orchestrator.NewEventBus() + lifecycle := newLocalLifecycleManager(GlobalPool, eventBus, currentPoolConfigs) + GlobalLifecycle = lifecycle + + localService := service.NewLocalDownloadService(lifecycle) GlobalService = localService - lifecycle, err := ensureLocalLifecycle(localService, currentPoolConfigs) + cleanup, err := startLifecycleEventWorker(localService, lifecycle) if err != nil { return err } - - lifecycle.SetEngineHooks(processing.EngineHooks{ - Pause: GlobalPool.Pause, - ExtractPausedConfig: GlobalPool.ExtractPausedConfig, - GetStatus: GlobalPool.GetStatus, - AddConfig: GlobalPool.Add, - Cancel: GlobalPool.Cancel, - UpdateURL: GlobalPool.UpdateURL, - PublishEvent: localService.Publish, - }) - - localService.SetLifecycleHooks(core.LifecycleHooks{ - Pause: lifecycle.Pause, - Resume: lifecycle.Resume, - ResumeBatch: lifecycle.ResumeBatch, - Cancel: lifecycle.Cancel, - UpdateURL: lifecycle.UpdateURL, - }) + GlobalLifecycleCleanup = cleanup } else { _, err := ensureLocalLifecycle(GlobalService, currentPoolConfigs) return err @@ -266,7 +246,8 @@ func ensureGlobalLocalServiceAndLifecycle() error { func publishSystemLog(message string) { if GlobalService != nil { - _ = GlobalService.Publish(events.SystemLogMsg{Message: message}) + _ = GlobalService.Publish(types.DownloadEvent{ + Type: types.EventSystem, Message: message}) return } fmt.Fprintln(os.Stderr, message) @@ -277,25 +258,26 @@ func recordPreflightDownloadError(url, outPath string, err error) { return } - filename := strings.TrimSpace(processing.InferFilenameFromURL(url)) + filename := strings.TrimSpace(orchestrator.InferFilenameFromURL(url)) destPath := "" if filename != "" && strings.TrimSpace(outPath) != "" { destPath = filepath.Join(outPath, filename) } - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: uuid.New().String(), URL: url, - URLHash: state.URLHash(url), + URLHash: store.URLHash(url), DestPath: destPath, Filename: filename, Status: "error", } - if addErr := state.AddToMasterList(entry); addErr != nil { + if addErr := store.AddToMasterList(entry); addErr != nil { utils.Debug("Failed to persist preflight download error for %s: %v", url, addErr) } if GlobalService != nil { - _ = GlobalService.Publish(events.DownloadErrorMsg{ + _ = GlobalService.Publish(types.DownloadEvent{ + Type: types.EventError, DownloadID: entry.ID, Filename: filename, DestPath: destPath, @@ -304,12 +286,13 @@ func recordPreflightDownloadError(url, outPath string, err error) { } } -func ensureLocalLifecycle(service core.DownloadService, getAll func() []types.DownloadConfig) (*processing.LifecycleManager, error) { +func ensureLocalLifecycle(service service.DownloadService, getAll func() []types.DownloadRecord) (*orchestrator.LifecycleManager, error) { globalLifecycleMu.Lock() defer globalLifecycleMu.Unlock() if GlobalLifecycle == nil { - GlobalLifecycle = newLocalLifecycleManager(service, getAll) + eventBus := orchestrator.NewEventBus() + GlobalLifecycle = newLocalLifecycleManager(GlobalPool, eventBus, getAll) } if GlobalLifecycleCleanup == nil { cleanup, err := startLifecycleEventWorker(service, GlobalLifecycle) @@ -468,9 +451,9 @@ var rootCmd = &cobra.Command{ fmt.Println("Settings and keybindings have been reset to defaults.") } } - GlobalProgressCh = make(chan any, 100) + GlobalProgressCh = make(chan types.DownloadEvent, 100) globalSettings = getSettings() - GlobalPool = download.NewWorkerPool(GlobalProgressCh, config.Resolve[int](globalSettings.Network.MaxConcurrentDownloads)) + GlobalPool = scheduler.New(GlobalProgressCh, config.Resolve[int](globalSettings.Network.MaxConcurrentDownloads)) }, RunE: func(cmd *cobra.Command, args []string) error { if ranRemote, err := maybeRunRemoteTUI(cmd, args); err != nil { @@ -558,7 +541,8 @@ func startTUI(port int, exitWhenDone bool, noResume bool) error { }() if startupIntegrityMessage != "" && GlobalService != nil { - _ = GlobalService.Publish(events.SystemLogMsg{ + _ = GlobalService.Publish(types.DownloadEvent{ + Type: types.EventSystem, Message: startupIntegrityMessage, }) startupIntegrityMessage = "" diff --git a/cmd/root_downloads.go b/cmd/root_downloads.go index f4405c96e..cb3c8733e 100644 --- a/cmd/root_downloads.go +++ b/cmd/root_downloads.go @@ -10,10 +10,9 @@ import ( "sync/atomic" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" "github.com/google/uuid" ) @@ -48,7 +47,7 @@ type resolvedDownloadRequest struct { isActive bool } -func handleDownload(w http.ResponseWriter, r *http.Request, defaultOutputDir string, service core.DownloadService) { +func handleDownload(w http.ResponseWriter, r *http.Request, defaultOutputDir string, service service.DownloadService) { if handleDownloadStatusRequest(w, r, service) { return } @@ -90,7 +89,7 @@ func handleDownload(w http.ResponseWriter, r *http.Request, defaultOutputDir str }) } -func handleDownloadStatusRequest(w http.ResponseWriter, r *http.Request, service core.DownloadService) bool { +func handleDownloadStatusRequest(w http.ResponseWriter, r *http.Request, service service.DownloadService) bool { if r.Method != http.MethodGet { return false } @@ -152,7 +151,7 @@ func validateDownloadRequest(req DownloadRequest) (DownloadRequest, error) { return req, nil } -func handleBatchDownload(w http.ResponseWriter, r *http.Request, defaultOutputDir string, service core.DownloadService) { +func handleBatchDownload(w http.ResponseWriter, r *http.Request, defaultOutputDir string, service service.DownloadService) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return @@ -174,7 +173,7 @@ func handleBatchDownload(w http.ResponseWriter, r *http.Request, defaultOutputDi settings := getSettings() sharedPath := utils.EnsureAbsPath(resolveOutputDir(req.Path, false, defaultOutputDir, settings)) - requests := make([]events.DownloadRequestMsg, 0, len(req.Downloads)) + requests := make([]types.DownloadEvent, 0, len(req.Downloads)) for _, item := range req.Downloads { if item.Path == "" { @@ -188,8 +187,9 @@ func handleBatchDownload(w http.ResponseWriter, r *http.Request, defaultOutputDi } urlForAdd, mirrorsForAdd := normalizeDownloadTargets(validated.URL, validated.Mirrors) itemPath := utils.EnsureAbsPath(resolveOutputDir(validated.Path, validated.RelativeToDefaultDir, defaultOutputDir, settings)) - requests = append(requests, events.DownloadRequestMsg{ - ID: uuid.New().String(), + requests = append(requests, types.DownloadEvent{ + Type: types.EventRequest, + DownloadID: uuid.New().String(), URL: urlForAdd, Filename: validated.Filename, Path: itemPath, @@ -209,10 +209,11 @@ func handleBatchDownload(w http.ResponseWriter, r *http.Request, defaultOutputDi return } batchID := uuid.New().String() - if err := service.Publish(events.BatchDownloadRequestMsg{ - ID: batchID, - Path: sharedPath, - Requests: requests, + if err := service.Publish(types.DownloadEvent{ + Type: types.EventBatchRequest, + DownloadID: batchID, + Path: sharedPath, + BatchEvents: requests, }); err != nil { http.Error(w, "Failed to notify TUI: "+err.Error(), http.StatusInternalServerError) return @@ -316,8 +317,8 @@ func normalizeDownloadTargets(url string, mirrors []string) (string, []string) { } func resolveDuplicateState(urlForAdd string, settings *config.Settings) (bool, bool) { - activeDownloadsFunc := func() map[string]*types.DownloadConfig { - active := make(map[string]*types.DownloadConfig) + activeDownloadsFunc := func() map[string]*types.DownloadRecord { + active := make(map[string]*types.DownloadRecord) for _, cfg := range GlobalPool.GetAll() { c := cfg active[c.ID] = &c @@ -325,14 +326,14 @@ func resolveDuplicateState(urlForAdd string, settings *config.Settings) (bool, b return active } - dupResult := processing.CheckForDuplicate(urlForAdd, activeDownloadsFunc) + dupResult := orchestrator.CheckForDuplicate(urlForAdd, activeDownloadsFunc) if dupResult == nil { return false, false } return dupResult.Exists, dupResult.IsActive } -func maybeRequireDownloadApproval(w http.ResponseWriter, service core.DownloadService, resolved *resolvedDownloadRequest) bool { +func maybeRequireDownloadApproval(w http.ResponseWriter, service service.DownloadService, resolved *resolvedDownloadRequest) bool { req := resolved.request // EXTENSION VETTING SHORTCUT: @@ -352,8 +353,9 @@ func maybeRequireDownloadApproval(w http.ResponseWriter, service core.DownloadSe utils.Debug("Requesting TUI confirmation for: %s (Duplicate: %v)", req.URL, resolved.isDuplicate) downloadID := uuid.New().String() - if err := service.Publish(events.DownloadRequestMsg{ - ID: downloadID, + if err := service.Publish(types.DownloadEvent{ + Type: types.EventRequest, + DownloadID: downloadID, URL: resolved.urlForAdd, Filename: req.Filename, Path: resolved.outPath, @@ -392,7 +394,7 @@ func maybeRequireDownloadApproval(w http.ResponseWriter, service core.DownloadSe return true } -func enqueueDownloadRequest(r *http.Request, service core.DownloadService, resolved *resolvedDownloadRequest) (string, string, error) { +func enqueueDownloadRequest(r *http.Request, service service.DownloadService, resolved *resolvedDownloadRequest) (string, string, error) { lifecycle, err := lifecycleForLocalService(service) if err != nil { return "", "", fmt.Errorf("failed to initialize lifecycle manager: %w", err) @@ -400,7 +402,7 @@ func enqueueDownloadRequest(r *http.Request, service core.DownloadService, resol req := resolved.request if lifecycle != nil { - return lifecycle.Enqueue(r.Context(), &processing.DownloadRequest{ + return lifecycle.Enqueue(r.Context(), &orchestrator.DownloadRequest{ URL: resolved.urlForAdd, Filename: req.Filename, Path: resolved.outPath, @@ -413,7 +415,7 @@ func enqueueDownloadRequest(r *http.Request, service core.DownloadService, resol }) } - id, err := service.Add(resolved.urlForAdd, resolved.outPath, req.Filename, resolved.mirrorsForAdd, req.Headers, req.IsExplicitCategory, req.Workers, req.MinChunkSize, 0, false) + id, err := service.Add(resolved.urlForAdd, resolved.outPath, req.Filename, resolved.mirrorsForAdd, req.Headers, req.IsExplicitCategory, req.Workers, req.MinChunkSize) return id, req.Filename, err } @@ -486,7 +488,7 @@ func processDownloads(urls []string, outputDir string, port int) int { continue } - _, _, err = lifecycle.Enqueue(currentEnqueueContext(), &processing.DownloadRequest{ + _, _, err = lifecycle.Enqueue(currentEnqueueContext(), &orchestrator.DownloadRequest{ URL: url, Path: outPath, Mirrors: mirrors, @@ -538,6 +540,14 @@ func mapClientWindowsPath(reqPath string, relativeToDefaultDir bool, defaultOutp return "" } + // On a Windows host, a Windows-absolute path is a real local path. + // filepath.IsAbs returns true for it, so the normal code path handles it + // correctly. Only remap when the daemon is running on a non-Windows OS + // (i.e. the path is from a Windows browser extension talking to a Linux/macOS daemon). + if runtime.GOOS == "windows" && filepath.IsAbs(reqPath) { + return "" + } + baseDir := "." if relativeToDefaultDir { if settings != nil && strings.TrimSpace(config.Resolve[string](settings.General.DefaultDownloadDir)) != "" { diff --git a/cmd/root_headless.go b/cmd/root_headless.go index e9cc3e1d1..b5b8cda94 100644 --- a/cmd/root_headless.go +++ b/cmd/root_headless.go @@ -5,13 +5,13 @@ import ( "fmt" "sync/atomic" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/engine/events" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) // StartHeadlessConsumer starts a goroutine to consume progress messages and log to stdout -func StartHeadlessConsumer(service core.DownloadService) { +func StartHeadlessConsumer(service service.DownloadService) { go func() { if service == nil { return @@ -24,23 +24,25 @@ func StartHeadlessConsumer(service core.DownloadService) { defer cleanup() for msg := range stream { - switch m := msg.(type) { - case events.DownloadStartedMsg: - fmt.Printf("Started: %s [%s]\n", m.Filename, truncateID(m.DownloadID)) - case events.DownloadCompleteMsg: + switch msg.Type { + case types.EventStarted: + fmt.Printf("Started: %s [%s]\n", msg.Filename, truncateID(msg.DownloadID)) + case types.EventComplete: atomic.AddInt32(&activeDownloads, -1) - fmt.Printf("Completed: %s [%s] (in %s)\n", m.Filename, truncateID(m.DownloadID), m.Elapsed) - case events.DownloadErrorMsg: + fmt.Printf("Completed: %s [%s] (in %s)\n", msg.Filename, truncateID(msg.DownloadID), msg.Elapsed) + case types.EventError: atomic.AddInt32(&activeDownloads, -1) - fmt.Printf("Error: %s [%s]: %v\n", m.Filename, truncateID(m.DownloadID), m.Err) - case events.DownloadQueuedMsg: - fmt.Printf("Queued: %s [%s]\n", m.Filename, truncateID(m.DownloadID)) - case events.DownloadPausedMsg: - fmt.Printf("Paused: %s [%s]\n", m.Filename, truncateID(m.DownloadID)) - case events.DownloadResumedMsg: - fmt.Printf("Resumed: %s [%s]\n", m.Filename, truncateID(m.DownloadID)) - case events.DownloadRemovedMsg: - fmt.Printf("Removed: %s [%s]\n", m.Filename, truncateID(m.DownloadID)) + fmt.Printf("Error: %s [%s]: %v\n", msg.Filename, truncateID(msg.DownloadID), msg.Err) + case types.EventQueued: + fmt.Printf("Queued: %s [%s]\n", msg.Filename, truncateID(msg.DownloadID)) + case types.EventPaused: + fmt.Printf("Paused: %s [%s]\n", msg.Filename, truncateID(msg.DownloadID)) + case types.EventResumed: + fmt.Printf("Resumed: %s [%s]\n", msg.Filename, truncateID(msg.DownloadID)) + case types.EventRemoved: + fmt.Printf("Removed: %s [%s]\n", msg.Filename, truncateID(msg.DownloadID)) + case types.EventProgress, types.EventRequest, types.EventBatchRequest, types.EventBatchProgress, types.EventSystem: + // Streaming/internal events are intentionally not logged to stdout. } } }() diff --git a/cmd/root_http_server.go b/cmd/root_http_server.go index 85074e387..92bbde9ca 100644 --- a/cmd/root_http_server.go +++ b/cmd/root_http_server.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" + "github.com/SurgeDM/Surge/internal/service" "github.com/SurgeDM/Surge/internal/utils" "github.com/google/uuid" ) @@ -68,7 +68,7 @@ func removeActivePort() { } // startHTTPServer starts the HTTP server using an existing listener -func startHTTPServer(ln net.Listener, port int, defaultOutputDir string, service core.DownloadService, tokenOverride string) { +func startHTTPServer(ln net.Listener, port int, defaultOutputDir string, service service.DownloadService, tokenOverride string) { authToken := strings.TrimSpace(tokenOverride) if authToken == "" { authToken = ensureAuthToken() diff --git a/cmd/root_lifecycle_test.go b/cmd/root_lifecycle_test.go index 783607c8a..bb3973583 100644 --- a/cmd/root_lifecycle_test.go +++ b/cmd/root_lifecycle_test.go @@ -13,31 +13,31 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) type countingLifecycleService struct { streamCalls atomic.Int32 - streamCh chan interface{} + streamCh chan types.DownloadEvent cleanupMu sync.Mutex cleaned bool logs []string } -var _ core.DownloadService = (*countingLifecycleService)(nil) +var _ service.DownloadService = (*countingLifecycleService)(nil) -func (s *countingLifecycleService) List() ([]types.DownloadStatus, error) { return nil, nil } -func (s *countingLifecycleService) History() ([]types.DownloadEntry, error) { return nil, nil } -func (s *countingLifecycleService) Add(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { +func (s *countingLifecycleService) List() ([]types.DownloadStatus, error) { return nil, nil } +func (s *countingLifecycleService) History() ([]types.DownloadRecord, error) { return nil, nil } +func (s *countingLifecycleService) Add(string, string, string, []string, map[string]string, bool, int, int64) (string, error) { return "", nil } -func (s *countingLifecycleService) AddWithID(string, string, string, []string, map[string]string, string, int, int64, int64, bool) (string, error) { +func (s *countingLifecycleService) AddWithID(string, string, string, []string, map[string]string, string, int, int64) (string, error) { return "", nil } func (s *countingLifecycleService) Pause(string) error { return nil } @@ -46,12 +46,10 @@ func (s *countingLifecycleService) ResumeBatch([]string) []error { return nil func (s *countingLifecycleService) UpdateURL(string, string) error { return nil } func (s *countingLifecycleService) Delete(string) error { return nil } func (s *countingLifecycleService) Purge(string) error { return nil } -func (s *countingLifecycleService) Publish(msg interface{}) error { - if log, ok := msg.(events.SystemLogMsg); ok { - s.cleanupMu.Lock() - s.logs = append(s.logs, log.Message) - s.cleanupMu.Unlock() - } +func (s *countingLifecycleService) Publish(msg types.DownloadEvent) error { + s.cleanupMu.Lock() + s.logs = append(s.logs, msg.Message) + s.cleanupMu.Unlock() return nil } func (s *countingLifecycleService) GetStatus(string) (*types.DownloadStatus, error) { return nil, nil } @@ -59,9 +57,9 @@ func (s *countingLifecycleService) Shutdown() error func (s *countingLifecycleService) SetRateLimit(string, int64) error { return nil } func (s *countingLifecycleService) ClearRateLimit(string) error { return nil } -func (s *countingLifecycleService) StreamEvents(context.Context) (<-chan interface{}, func(), error) { +func (s *countingLifecycleService) StreamEvents(context.Context) (<-chan types.DownloadEvent, func(), error) { s.streamCalls.Add(1) - ch := make(chan interface{}) + ch := make(chan types.DownloadEvent) s.streamCh = ch cleanup := func() { s.cleanupMu.Lock() @@ -84,15 +82,15 @@ func (s *countingLifecycleService) ClearFailed() (int64, error) { } func TestBuildActiveDownloadChecker(t *testing.T) { - getAll := func() []types.DownloadConfig { - state := types.NewProgressState("dl-2", 0) + getAll := func() []types.DownloadRecord { + state := progress.New("dl-2", 0) state.SetFilename("from-state.iso") state.SetDestPath("/downloads/from-state.iso") - return []types.DownloadConfig{ + return []types.DownloadRecord{ {Filename: "queued.zip", OutputPath: "/downloads"}, {DestPath: "/downloads/from-path.mp4"}, - {State: state}, + {ProgressState: state}, } } @@ -116,11 +114,11 @@ func TestBuildActiveDownloadChecker(t *testing.T) { } func TestNewLocalLifecycleManager_WiresNameActivityCheck(t *testing.T) { - getAll := func() []types.DownloadConfig { - return []types.DownloadConfig{{Filename: "active.bin", OutputPath: "."}} + getAll := func() []types.DownloadRecord { + return []types.DownloadRecord{{Filename: "active.bin", OutputPath: "."}} } - mgr := newLocalLifecycleManager(nil, getAll) + mgr := newLocalLifecycleManager(nil, nil, getAll) if !mgr.IsNameActive(".", "active.bin") { t.Fatal("expected wired IsNameActive callback to inspect active downloads") } @@ -130,9 +128,12 @@ func TestEnsureLocalLifecycle_StartsEventWorker(t *testing.T) { setupIsolatedCmdState(t) GlobalLifecycle = nil GlobalLifecycleCleanup = nil - GlobalProgressCh = make(chan any, 32) - GlobalPool = download.NewWorkerPool(GlobalProgressCh, 1) - GlobalService = core.NewLocalDownloadServiceWithInput(GlobalPool, GlobalProgressCh) + GlobalProgressCh = make(chan types.DownloadEvent, 32) + GlobalPool = scheduler.New(GlobalProgressCh, 1) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + GlobalService = service.NewLocalDownloadService(GlobalLifecycle) t.Cleanup(func() { if GlobalLifecycleCleanup != nil { GlobalLifecycleCleanup() @@ -167,7 +168,7 @@ func TestEnsureLocalLifecycle_StartsEventWorker(t *testing.T) { deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { - entries, err := state.ListAllDownloads() + entries, err := store.ListAllDownloads() if err == nil { for _, entry := range entries { if strings.HasSuffix(entry.DestPath, fmt.Sprintf("%clocal.bin", filepath.Separator)) { @@ -178,7 +179,7 @@ func TestEnsureLocalLifecycle_StartsEventWorker(t *testing.T) { time.Sleep(25 * time.Millisecond) } - entries, err := state.ListAllDownloads() + entries, err := store.ListAllDownloads() if err != nil { t.Fatalf("failed to list downloads: %v", err) } @@ -227,9 +228,12 @@ func TestProcessDownloads_RoutesBinFilesToCustomCategory(t *testing.T) { setupIsolatedCmdState(t) GlobalLifecycle = nil GlobalLifecycleCleanup = nil - GlobalProgressCh = make(chan any, 32) - GlobalPool = download.NewWorkerPool(GlobalProgressCh, 1) - GlobalService = core.NewLocalDownloadServiceWithInput(GlobalPool, GlobalProgressCh) + eventBus := orchestrator.NewEventBus() + GlobalProgressCh = eventBus.InputCh + GlobalPool = scheduler.New(GlobalProgressCh, 1) + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + GlobalService = service.NewLocalDownloadService(GlobalLifecycle) t.Cleanup(func() { if GlobalLifecycleCleanup != nil { GlobalLifecycleCleanup() @@ -260,6 +264,7 @@ func TestProcessDownloads_RoutesBinFilesToCustomCategory(t *testing.T) { if err := config.SaveSettings(settings); err != nil { t.Fatalf("SaveSettings failed: %v", err) } + GlobalLifecycle.ApplySettings(settings) const filename = "artifact.bin" const fileSize = int64(64 * 1024) @@ -287,7 +292,7 @@ func TestProcessDownloads_RoutesBinFilesToCustomCategory(t *testing.T) { t.Fatalf("expected no file in default dir, stat err: %v", err) } - entries, err := state.ListAllDownloads() + entries, err := store.ListAllDownloads() if err != nil { t.Fatalf("failed to list downloads: %v", err) } @@ -306,7 +311,7 @@ func TestProcessDownloads_RoutesBinFilesToCustomCategory(t *testing.T) { if _, err := os.Stat(unexpectedPath); !os.IsNotExist(err) { t.Fatalf("expected no file in default dir, stat err: %v", err) } - entries, err := state.ListAllDownloads() + entries, err := store.ListAllDownloads() if err != nil { t.Fatalf("failed to list downloads: %v", err) } @@ -317,9 +322,12 @@ func TestProcessDownloads_UsesLatestSavedCategorySettings(t *testing.T) { setupIsolatedCmdState(t) GlobalLifecycle = nil GlobalLifecycleCleanup = nil - GlobalProgressCh = make(chan any, 32) - GlobalPool = download.NewWorkerPool(GlobalProgressCh, 1) - GlobalService = core.NewLocalDownloadServiceWithInput(GlobalPool, GlobalProgressCh) + eventBus := orchestrator.NewEventBus() + GlobalProgressCh = eventBus.InputCh + GlobalPool = scheduler.New(GlobalProgressCh, 1) + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + GlobalService = service.NewLocalDownloadService(GlobalLifecycle) t.Cleanup(func() { if GlobalLifecycleCleanup != nil { GlobalLifecycleCleanup() @@ -411,7 +419,7 @@ func TestEnsureLocalLifecycle_ConcurrentInitializationStartsOneStream(t *testing service := &countingLifecycleService{} const callers = 12 - results := make(chan *processing.LifecycleManager, callers) + results := make(chan *orchestrator.LifecycleManager, callers) errs := make(chan error, callers) var wg sync.WaitGroup @@ -435,7 +443,7 @@ func TestEnsureLocalLifecycle_ConcurrentInitializationStartsOneStream(t *testing t.Fatalf("ensureLocalLifecycle returned error: %v", err) } - var first *processing.LifecycleManager + var first *orchestrator.LifecycleManager for mgr := range results { if first == nil { first = mgr @@ -462,7 +470,7 @@ func TestProcessDownloads_UsesSharedEnqueueContext(t *testing.T) { setupIsolatedCmdState(t) service := &countingLifecycleService{} GlobalService = service - GlobalPool = download.NewWorkerPool(nil, 1) + GlobalPool = scheduler.New(nil, 1) GlobalLifecycleCleanup = nil t.Cleanup(func() { GlobalService = nil @@ -479,21 +487,16 @@ func TestProcessDownloads_UsesSharedEnqueueContext(t *testing.T) { })) defer server.Close() - dispatchCalled := false - GlobalLifecycle = processing.NewLifecycleManager( - func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { - dispatchCalled = true - return "", nil - }, - nil, - ) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) cancelGlobalEnqueue() count := processDownloads([]string{server.URL + "/shared-context.bin"}, t.TempDir(), 0) if count != 0 { t.Fatalf("count = %d, want 0 after canceled enqueue context", count) } - if dispatchCalled { + if len(GlobalPool.GetAll()) > 0 { t.Fatal("expected canceled enqueue context to stop before dispatch") } if len(service.logs) == 0 { diff --git a/cmd/root_startup.go b/cmd/root_startup.go index 6361febdd..f7a5b2241 100644 --- a/cmd/root_startup.go +++ b/cmd/root_startup.go @@ -6,7 +6,7 @@ import ( "sync/atomic" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/state" + "github.com/SurgeDM/Surge/internal/store" "github.com/SurgeDM/Surge/internal/utils" ) @@ -14,7 +14,7 @@ func runStartupIntegrityCheck() string { // Normalize downloads stuck in "downloading" status from a prior crash/kill. // This must happen before ValidateIntegrity so the newly-paused entries // are included in the integrity check and eligible for auto-resume. - if normalized, err := state.NormalizeStaleDownloads(); err != nil { + if normalized, err := store.NormalizeStaleDownloads(); err != nil { msg := fmt.Sprintf("Startup: failed to normalize stale downloads: %v", err) utils.Debug("%s", msg) } else if normalized > 0 { @@ -24,7 +24,7 @@ func runStartupIntegrityCheck() string { // Validate integrity of paused/queued downloads before auto-resume. // This removes entries whose .surge files are missing/tampered and // also cleans orphan .surge files that no longer have DB entries. - if removed, err := state.ValidateIntegrity(); err != nil { + if removed, err := store.ValidateIntegrity(); err != nil { msg := fmt.Sprintf("Startup integrity check failed: %v", err) return msg } else if removed > 0 { @@ -46,7 +46,7 @@ func initializeGlobalState() error { } // Config engine state - state.Configure(stateDBPath) + store.Configure(stateDBPath) // Config logging utils.ConfigureDebug(logsDir) @@ -68,7 +68,11 @@ func getSettings() *config.Settings { } settings, err := config.LoadSettings() if err != nil { - return config.DefaultSettings() + utils.Debug("Warning: failed to load settings: %v - using defaults", err) + defaults := config.DefaultSettings() + defaults.StartupWarnings = append(defaults.StartupWarnings, + fmt.Sprintf("Config: failed to read settings file (%v) - all settings reset to defaults", err)) + return defaults } return settings } @@ -76,7 +80,7 @@ func getSettings() *config.Settings { func resumePausedDownloads() { settings := getSettings() - pausedEntries, err := state.LoadPausedDownloads() + pausedEntries, err := store.LoadPausedDownloads() if err != nil { return } diff --git a/cmd/service_kardianos_test.go b/cmd/service_kardianos_test.go index 6ac942011..979e1d5ba 100644 --- a/cmd/service_kardianos_test.go +++ b/cmd/service_kardianos_test.go @@ -3,6 +3,7 @@ package cmd import ( + "net" "testing" "time" @@ -51,6 +52,12 @@ func waitStop(t *testing.T, p *program, s service.Service) { } func TestProgramLifecycle(t *testing.T) { + ln, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Skipf("Skipping test because tcp listener cannot bind (e.g. Windows dual-stack provider issue): %v", err) + } + _ = ln.Close() + p := &program{} s := &mockService{} @@ -59,7 +66,7 @@ func TestProgramLifecycle(t *testing.T) { defer rootCmd.SetArgs(nil) // Test Start - err := p.Start(s) + err = p.Start(s) assert.NoError(t, err) assert.NotNil(t, p.cancel) assert.NotNil(t, p.exit) @@ -94,6 +101,12 @@ func TestToggleServiceFunc(t *testing.T) { } func TestProgramContextCancellation(t *testing.T) { + ln, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Skipf("Skipping test because tcp listener cannot bind (e.g. Windows dual-stack provider issue): %v", err) + } + _ = ln.Close() + p := &program{} s := &mockService{} diff --git a/cmd/shutdown_test.go b/cmd/shutdown_test.go index 13fdbf21c..b77e88165 100644 --- a/cmd/shutdown_test.go +++ b/cmd/shutdown_test.go @@ -6,7 +6,7 @@ import ( "sync/atomic" "testing" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) func TestExecuteGlobalShutdown_Once(t *testing.T) { @@ -39,8 +39,8 @@ type fakeShutdownService struct { onShutdown func() } -func (f *fakeShutdownService) StreamEvents(context.Context) (<-chan interface{}, func(), error) { - ch := make(chan interface{}) +func (f *fakeShutdownService) StreamEvents(context.Context) (<-chan types.DownloadEvent, func(), error) { + ch := make(chan types.DownloadEvent) return ch, func() { close(ch) }, nil } diff --git a/cmd/startup_test.go b/cmd/startup_test.go index dcf479ca9..f137c3bce 100644 --- a/cmd/startup_test.go +++ b/cmd/startup_test.go @@ -3,15 +3,16 @@ package cmd import ( "os" "path/filepath" + "runtime" "testing" "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -33,29 +34,12 @@ func TestServer_Startup_HandlesResume(t *testing.T) { seedDownload(t, testID, testURL, testDest, "queued") // 3. Initialize Global Pool (required for resumePausedDownloads) - GlobalProgressCh = make(chan any, 10) - GlobalPool = download.NewWorkerPool(GlobalProgressCh, 3) - GlobalService = core.NewLocalDownloadServiceWithInput(GlobalPool, GlobalProgressCh) - - GlobalLifecycle = processing.NewLifecycleManager(nil, nil, nil) - GlobalLifecycle.SetEngineHooks(processing.EngineHooks{ - Pause: GlobalPool.Pause, - ExtractPausedConfig: GlobalPool.ExtractPausedConfig, - AddConfig: GlobalPool.Add, - GetStatus: GlobalPool.GetStatus, - Cancel: GlobalPool.Cancel, - UpdateURL: GlobalPool.UpdateURL, - PublishEvent: GlobalService.Publish, - }) - if svc, ok := GlobalService.(*core.LocalDownloadService); ok { - svc.SetLifecycleHooks(core.LifecycleHooks{ - Pause: GlobalLifecycle.Pause, - Resume: GlobalLifecycle.Resume, - ResumeBatch: GlobalLifecycle.ResumeBatch, - Cancel: GlobalLifecycle.Cancel, - UpdateURL: GlobalLifecycle.UpdateURL, - }) - } + GlobalProgressCh = make(chan types.DownloadEvent, 10) + GlobalPool = scheduler.New(GlobalProgressCh, 3) + eventBus := orchestrator.NewEventBus() + getAll := func() []types.DownloadRecord { return GlobalPool.GetAll() } + GlobalLifecycle = orchestrator.NewLifecycleManager(GlobalPool, eventBus, buildActiveDownloadChecker(getAll)) + GlobalService = service.NewLocalDownloadService(GlobalLifecycle) defer func() { if GlobalService != nil { _ = GlobalService.Shutdown() @@ -105,7 +89,7 @@ func TestStartupIntegrityCheck_RemovesMissingPausedEntry(t *testing.T) { msg := runStartupIntegrityCheck() utils.Debug("%s", msg) - entry, err := state.GetDownload(testID) + entry, err := store.GetDownload(testID) if err != nil { t.Fatalf("GetDownload failed: %v", err) } @@ -141,12 +125,12 @@ func setupTestEnv(t *testing.T, tmpDir string) { // Configure DB dbPath := filepath.Join(surgeDir, "state", "surge.db") _ = os.MkdirAll(filepath.Dir(dbPath), 0o755) - state.CloseDB() - state.Configure(dbPath) + store.CloseDB() + store.Configure(dbPath) } func seedDownload(t *testing.T, id, url, dest, status string) { - manualState := &types.DownloadState{ + manualState := &types.DownloadRecord{ ID: id, URL: url, Filename: filepath.Base(dest), @@ -156,7 +140,7 @@ func seedDownload(t *testing.T, id, url, dest, status string) { PausedAt: 0, CreatedAt: time.Now().Unix(), } - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: id, URL: url, DestPath: dest, @@ -167,7 +151,46 @@ func seedDownload(t *testing.T, id, url, dest, status string) { }); err != nil { t.Fatal(err) } - if err := state.SaveState(url, dest, manualState); err != nil { + if err := store.SaveState(url, dest, manualState); err != nil { + t.Fatal(err) + } +} + +func TestGetSettings_LoadError_PopulatesStartupWarnings(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("skipping permission test on windows") + } + + tmpDir, err := os.MkdirTemp("", "surge-getsettings-test") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + originalXDG := os.Getenv("XDG_CONFIG_HOME") + _ = os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer func() { + if originalXDG == "" { + _ = os.Unsetenv("XDG_CONFIG_HOME") + } else { + _ = os.Setenv("XDG_CONFIG_HOME", originalXDG) + } + }() + + // Force an error when reading settings.toml by creating a directory with its name + surgeDir := config.GetSurgeDir() + if err := os.MkdirAll(surgeDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(config.GetSettingsPath(), 0o755); err != nil { t.Fatal(err) } + + globalSettings = nil // Ensure we don't return cached settings + defer func() { globalSettings = nil }() + + settings := getSettings() + if len(settings.StartupWarnings) == 0 { + t.Error("expected StartupWarnings when LoadSettings fails") + } } diff --git a/cmd/test_env_test.go b/cmd/test_env_test.go index bc62a6582..ec01403d6 100644 --- a/cmd/test_env_test.go +++ b/cmd/test_env_test.go @@ -4,6 +4,7 @@ import ( "sync" "testing" + "github.com/SurgeDM/Surge/internal/utils" "github.com/adrg/xdg" ) @@ -28,6 +29,7 @@ func setupXDGEnvIsolation(t *testing.T) string { xdg.RuntimeDir = tempDir t.Cleanup(func() { + utils.CloseDebug() xdg.ConfigHome = oldConfigHome xdg.DataHome = oldDataHome xdg.StateHome = oldStateHome @@ -41,6 +43,7 @@ func setupXDGEnvIsolation(t *testing.T) string { t.Setenv("APPDATA", tempDir) t.Setenv("USERPROFILE", tempDir) + t.Setenv("SystemRoot", tempDir) t.Setenv("XDG_CONFIG_HOME", tempDir) t.Setenv("XDG_DATA_HOME", tempDir) t.Setenv("XDG_STATE_HOME", tempDir) diff --git a/cmd/utils.go b/cmd/utils.go index ea87faedc..88b2a0716 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -13,8 +13,8 @@ import ( "strings" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -397,7 +397,7 @@ func resolveDownloadID(partialID string) (string, error) { } // 2. Get all downloads from database - downloads, err := state.ListAllDownloads() + downloads, err := store.ListAllDownloads() if err == nil { for _, d := range downloads { candidates = append(candidates, d.ID) diff --git a/extension/package-lock.json b/extension/package-lock.json index 0923847a4..4ea74b0cd 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -487,21 +487,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -510,9 +510,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1282,14 +1282,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1301,9 +1301,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, "license": "MIT", "funding": { @@ -1356,9 +1356,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -1373,9 +1373,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -1390,9 +1390,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -1407,9 +1407,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -1424,9 +1424,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -1441,9 +1441,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], @@ -1461,9 +1461,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], @@ -1481,9 +1481,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], @@ -1501,9 +1501,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], @@ -1521,9 +1521,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], @@ -1541,9 +1541,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], @@ -1561,9 +1561,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -1578,9 +1578,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ "wasm32" ], @@ -1588,18 +1588,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], @@ -1614,9 +1614,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -1645,9 +1645,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -1764,13 +1764,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~8.3.0" } }, "node_modules/@types/webextension-polyfill": { @@ -1781,17 +1781,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", - "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/type-utils": "8.61.1", - "@typescript-eslint/utils": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1804,22 +1804,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.1", + "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", - "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "engines": { @@ -1835,14 +1835,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", - "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.1", - "@typescript-eslint/types": "^8.61.1", + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "engines": { @@ -1857,14 +1857,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", - "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1" + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1875,9 +1875,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", - "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", "dev": true, "license": "MIT", "engines": { @@ -1892,15 +1892,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", - "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1917,9 +1917,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", - "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -1931,16 +1931,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", - "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.1", - "@typescript-eslint/tsconfig-utils": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1959,16 +1959,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", - "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1" + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1983,13 +1983,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", - "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2151,16 +2151,19 @@ } }, "node_modules/@webext-core/match-patterns": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@webext-core/match-patterns/-/match-patterns-1.0.3.tgz", - "integrity": "sha512-NY39ACqCxdKBmHgw361M9pfJma8e4AZo20w9AY+5ZjIj1W2dvXC8J31G5fjfOGbulW9w4WKpT8fPooi0mLkn9A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@webext-core/match-patterns/-/match-patterns-1.1.0.tgz", + "integrity": "sha512-vebVVbcOyva4jyvljIzRJwjFi/OKMLr96LIIxiPeXfA38gE4Z3+H6Y9DwRmn7pWErJGNNHU6XOhOb5ZwicGs7Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "*" + } }, "node_modules/@wxt-dev/browser": { - "version": "0.1.43", - "resolved": "https://registry.npmjs.org/@wxt-dev/browser/-/browser-0.1.43.tgz", - "integrity": "sha512-RMB0zgfe7uuiTp18s9taLeKXoOCLBV418797dnEggiMWmM1JDJekiLtlvrNc6QeZg+amDBy2/KKYuQB2kh/K9A==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@wxt-dev/browser/-/browser-0.2.2.tgz", + "integrity": "sha512-QqLOdEE1UQxieRuMbv9rwczD+xUv45fy+i5gw1eo+/vlPtGX+/rBd6tlIfLFCU3xAN/UTH57bxqOeU2IZg7dEg==", "dev": true, "license": "MIT", "dependencies": { @@ -2183,6 +2186,17 @@ "url": "https://github.com/sponsors/wxt-dev" } }, + "node_modules/@wxt-dev/storage/node_modules/@wxt-dev/browser": { + "version": "0.1.43", + "resolved": "https://registry.npmjs.org/@wxt-dev/browser/-/browser-0.1.43.tgz", + "integrity": "sha512-RMB0zgfe7uuiTp18s9taLeKXoOCLBV418797dnEggiMWmM1JDJekiLtlvrNc6QeZg+amDBy2/KKYuQB2kh/K9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -2207,9 +2221,9 @@ } }, "node_modules/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "version": "0.5.18", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.18.tgz", + "integrity": "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng==", "dev": true, "license": "MIT", "engines": { @@ -2477,9 +2491,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2609,9 +2623,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -2622,9 +2636,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -2642,10 +2656,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -2748,9 +2762,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", "dev": true, "funding": [ { @@ -3461,9 +3475,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "version": "1.5.384", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", + "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==", "dev": true, "license": "ISC" }, @@ -3511,9 +3525,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "dev": true, "license": "MIT" }, @@ -3863,9 +3877,9 @@ "license": "MIT" }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3873,9 +3887,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", - "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", "dev": true, "license": "MIT" }, @@ -3942,9 +3956,9 @@ } }, "node_modules/filesize": { - "version": "11.0.17", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.17.tgz", - "integrity": "sha512-oHLTvMLw6imZUl1se/RBQrFlyy50nXce4sU7yGR6Qc0JgCwqnfiFsAnEwotdGmfKLD7SArGUk2/5STU0k8LOBQ==", + "version": "11.0.19", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.19.tgz", + "integrity": "sha512-9Q2itINBvCHwFw9v5X7czZm855yAiFIYlnugrh7+8tYO4ITHZMzV91m35q2FngL9hoZwwjSTQACFF/W52OzJZQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4030,9 +4044,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "dev": true, "license": "MIT", "dependencies": { @@ -4686,9 +4700,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -5244,9 +5258,9 @@ } }, "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", + "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", "dev": true, "license": "MIT", "dependencies": { @@ -5660,9 +5674,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -5757,9 +5771,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, "license": "MIT", "engines": { @@ -5790,9 +5804,9 @@ } }, "node_modules/nypm": { - "version": "0.6.7", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.7.tgz", - "integrity": "sha512-s3ds97SD5pd1dULE+tHUk1DrV0cSHOnsfpcdGATJ8JpBo21DoKqN9exTH4/2nhPQNOLomBdTFMicN94S4DrZrQ==", + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz", + "integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==", "dev": true, "license": "MIT", "dependencies": { @@ -6125,9 +6139,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -6483,13 +6497,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -6499,21 +6513,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/run-applescript": { @@ -6578,9 +6592,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -6732,14 +6746,14 @@ } }, "node_modules/solid-js": { - "version": "1.9.13", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.13.tgz", - "integrity": "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==", + "version": "1.9.14", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.14.tgz", + "integrity": "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==", "license": "MIT", "dependencies": { "csstype": "^3.1.0", - "seroval": "~1.5.0", - "seroval-plugins": "~1.5.0" + "seroval": "~1.5.4", + "seroval-plugins": "~1.5.4" } }, "node_modules/solid-refresh": { @@ -7132,16 +7146,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", - "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1" + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7170,9 +7184,9 @@ "license": "ISC" }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, "license": "MIT" }, @@ -7238,29 +7252,69 @@ } }, "node_modules/unplugin": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", - "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "picomatch": "^4.0.3", + "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "engines": { "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/unplugin-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", - "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", "dev": true, "license": "MIT", "dependencies": { "pathe": "^2.0.3", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=20.19.0" @@ -7367,16 +7421,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "bin": { @@ -7393,7 +7447,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -7823,9 +7877,9 @@ } }, "node_modules/wxt": { - "version": "0.20.26", - "resolved": "https://registry.npmjs.org/wxt/-/wxt-0.20.26.tgz", - "integrity": "sha512-PMGz7sAlONJgwBkOriInXOoEU6/jlGKrhSFvZfiBPHZocyYPfnw1lod9rGDra957H83WO+TnGjYwJiGYciSIqA==", + "version": "0.20.27", + "resolved": "https://registry.npmjs.org/wxt/-/wxt-0.20.27.tgz", + "integrity": "sha512-dm6yixz2awM4YMqpTJnsCa8aOPiTrjiIjbWslgR5hCjgwhuft+hLlla339Dt7gIKwQloee4oOruoK++vaX0APA==", "dev": true, "license": "MIT", "dependencies": { @@ -7834,10 +7888,10 @@ "@webext-core/fake-browser": "^1.3.4", "@webext-core/isolated-element": "^1.1.3", "@webext-core/match-patterns": "^1.0.3", - "@wxt-dev/browser": "^0.1.42", + "@wxt-dev/browser": "^0.2.0", "@wxt-dev/storage": "^1.0.0", "async-mutex": "^0.5.0", - "c12": "^3.3.3", + "c12": "^3.3.4", "cac": "^6.7.14 || ^7.0.0", "chokidar": "^5.0.0", "ci-info": "^4.4.0", @@ -7845,7 +7899,7 @@ "defu": "^6.1.4", "dotenv-expand": "^12.0.3", "esbuild": "^0.27.1", - "filesize": "^11.0.15", + "filesize": "^11.0.17", "get-port-please": "^3.2.0", "giget": "^1.2.3 || ^2.0.0 || ^3.0.0", "hookable": "^6.1.0", @@ -7866,7 +7920,7 @@ "prompts": "^2.4.2", "publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.5", "scule": "^1.3.0", - "tinyglobby": "^0.2.15", + "tinyglobby": "^0.2.16", "unimport": "^3.13.1 || ^4.0.0 || ^5.0.0 || ^6.0.0", "vite": "^5.4.19 || ^6.3.4 || ^7.0.0 || ^8.0.0-0", "vite-node": "^3.2.4 || ^5.0.0 || ^6.0.0", @@ -7947,9 +8001,9 @@ "license": "ISC" }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/extension/package.json b/extension/package.json index b59d66a11..df49559b3 100644 --- a/extension/package.json +++ b/extension/package.json @@ -14,6 +14,7 @@ "zip:edge": "wxt zip -b edge", "check": "tsc --noEmit", "lint": "eslint .", + "pretest": "wxt prepare", "test": "vitest run" }, "dependencies": { diff --git a/extension/test/background-sse.integration.test.ts b/extension/test/background-sse.integration.test.ts index 52948d975..4f1495e71 100644 --- a/extension/test/background-sse.integration.test.ts +++ b/extension/test/background-sse.integration.test.ts @@ -78,7 +78,7 @@ describe('background SSE integration with Go server', () => { const body = await authedResp.text(); expect(body).toContain('event: queued'); - expect(body).toContain('"DownloadID":"queue-1"'); - expect(body).toContain('"Filename":"archive.zip"'); + expect(body).toContain('"download_id":"queue-1"'); + expect(body).toContain('"filename":"archive.zip"'); }, 60_000); }); diff --git a/extension/test/go/sse_auth_server.go b/extension/test/go/sse_auth_server.go index b1db878ec..de20760e0 100644 --- a/extension/test/go/sse_auth_server.go +++ b/extension/test/go/sse_auth_server.go @@ -9,7 +9,7 @@ import ( "strings" "syscall" - "github.com/SurgeDM/Surge/internal/engine/events" + "github.com/SurgeDM/Surge/internal/types" ) func main() { @@ -25,7 +25,8 @@ func main() { return } - frames, err := events.EncodeSSEMessages(events.DownloadQueuedMsg{ + frames, err := types.EncodeSSEMessages(types.DownloadEvent{ + Type: types.EventQueued, DownloadID: "queue-1", Filename: "archive.zip", URL: "https://example.com/archive.zip", diff --git a/internal/config/keymaps.go b/internal/config/keymaps.go index 5056e9700..aa7df4fd3 100644 --- a/internal/config/keymaps.go +++ b/internal/config/keymaps.go @@ -211,7 +211,6 @@ func GetKeyMapConfigPath() string { // LoadKeyMap loads the keymap configuration from file. func LoadKeyMap() (*KeyMap, error) { - defaults := DefaultKeyMap() path := GetKeyMapConfigPath() utils.Debug("Loading keymap from %s", path) diff --git a/internal/config/settings.go b/internal/config/settings.go index e0b25448a..2cc7203f5 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -14,7 +14,7 @@ import ( "sync" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" "github.com/pelletier/go-toml/v2" ) diff --git a/internal/core/local_service.go b/internal/core/local_service.go deleted file mode 100644 index 77711fcf2..000000000 --- a/internal/core/local_service.go +++ /dev/null @@ -1,915 +0,0 @@ -package core - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/utils" - "github.com/google/uuid" -) - -func completedSpeedBps(entry types.DownloadEntry) float64 { - if entry.Status != "completed" { - return 0 - } - if entry.AvgSpeed > 0 { - return entry.AvgSpeed - } - if entry.TimeTaken > 0 { - return float64(entry.TotalSize) * 1000 / float64(entry.TimeTaken) - } - return 0 -} - -// ReloadSettings reloads settings from disk -func (s *LocalDownloadService) ReloadSettings() error { - settings, err := config.LoadSettings() - if err != nil { - return err - } - s.settingsMu.Lock() - s.settings = settings - s.settingsMu.Unlock() - if s.Pool != nil && settings != nil { - runtime := settings.ToRuntimeConfig() - s.Pool.SetGlobalRateLimit(runtime.GlobalRateLimitBps) - s.Pool.SetDefaultDownloadRateLimit(runtime.DefaultDownloadRateLimitBps) - } - return nil -} - -// LocalDownloadService implements DownloadService for the local embedded engine. -type LocalDownloadService struct { - Pool *download.WorkerPool - InputCh chan interface{} - - // Broadcast fields - listeners []chan interface{} - listenerMu sync.Mutex - - broadcastWG sync.WaitGroup - reportTicker *time.Ticker - reportWG sync.WaitGroup - - // Lifecycle - ctx context.Context - cancel context.CancelFunc - // shutdownOnce guarantees Shutdown is safe to call multiple times. - shutdownOnce sync.Once - shutdownErr error - - // Settings Cache - settings *config.Settings - settingsMu sync.RWMutex - - lifecycleHooks LifecycleHooks - lifecycleHooksMu sync.RWMutex -} - -// LifecycleHooks routes service-level management calls through the LifecycleManager. -type LifecycleHooks struct { - Pause func(id string) error - Resume func(id string) error - ResumeBatch func(ids []string) []error - Cancel func(id string) error - UpdateURL func(id, newURL string) error -} - -const ( - SpeedSmoothingAlpha = 0.3 - ReportInterval = 150 * time.Millisecond -) - -// NewLocalDownloadService creates a new specific service instance. -func NewLocalDownloadService(pool *download.WorkerPool) *LocalDownloadService { - return NewLocalDownloadServiceWithInput(pool, nil) -} - -// NewLocalDownloadServiceWithInput creates a service using a provided input channel. -// If inputCh is nil, a new buffered channel is created. -func NewLocalDownloadServiceWithInput(pool *download.WorkerPool, inputCh chan interface{}) *LocalDownloadService { - if inputCh == nil { - inputCh = make(chan interface{}, 100) - } - s := &LocalDownloadService{ - Pool: pool, - InputCh: inputCh, - listeners: make([]chan interface{}, 0), - } - - // Load initial settings - if s.settings, _ = config.LoadSettings(); s.settings == nil { - s.settings = config.DefaultSettings() - } - if pool != nil && s.settings != nil { - runtime := s.settings.ToRuntimeConfig() - pool.SetGlobalRateLimit(runtime.GlobalRateLimitBps) - pool.SetDefaultDownloadRateLimit(runtime.DefaultDownloadRateLimitBps) - } - - // Lifecycle - ctx, cancel := context.WithCancel(context.Background()) - s.ctx = ctx - s.cancel = cancel - - // Start broadcaster - s.broadcastWG.Add(1) - go func() { - defer s.broadcastWG.Done() - s.broadcastLoop() - }() - - // Start progress reporter - if pool != nil { - s.reportTicker = time.NewTicker(ReportInterval) - s.reportWG.Add(1) - go func() { - defer s.reportWG.Done() - s.reportProgressLoop() - }() - } - - return s -} - -func (s *LocalDownloadService) broadcastLoop() { - for msg := range s.InputCh { - s.listenerMu.Lock() - listenersCopy := make([]chan any, len(s.listeners)) - copy(listenersCopy, s.listeners) - s.listenerMu.Unlock() - - for _, ch := range listenersCopy { - // Check message type - isProgress := false - switch msg.(type) { - case events.ProgressMsg: - isProgress = true - case events.BatchProgressMsg: - isProgress = true - } - - func() { - defer func() { _ = recover() }() - if isProgress { - // Non-blocking send for progress updates - select { - case ch <- msg: - default: - // Drop progress message if channel is full - } - } else { - // Blocking send with timeout for critical state changes - // We don't want to drop these, but we also don't want to block forever if a client is dead - select { - case ch <- msg: - case <-time.After(1 * time.Second): - utils.Debug("Dropped critical event due to slow client") - } - } - }() - } - } - // Close all listeners when input closes - s.listenerMu.Lock() - for _, ch := range s.listeners { - close(ch) - } - s.listeners = nil - s.listenerMu.Unlock() - - if s.reportTicker != nil { - s.reportTicker.Stop() - } -} - -func (s *LocalDownloadService) reportProgressLoop() { - lastSpeeds := make(map[string]float64) - lastChunkSnapshot := make(map[string]time.Time) - - if s.reportTicker == nil { - return - } - - for { - select { - case <-s.ctx.Done(): - return - case <-s.reportTicker.C: - } - - if s.Pool == nil { - continue - } - alpha := s.getSpeedEmaAlpha() - - var batch events.BatchProgressMsg - - activeConfigs := s.Pool.GetAll() - for _, cfg := range activeConfigs { - if cfg.State == nil || cfg.State.IsPaused() || cfg.State.Done.Load() { - // Clean up speed history for inactive - delete(lastSpeeds, cfg.ID) - delete(lastChunkSnapshot, cfg.ID) - continue - } - - // Calculate Progress - downloaded, total, totalElapsed, sessionElapsed, connections, sessionStart := cfg.State.GetProgress() - - // Calculate Speed with EMA - sessionDownloaded := downloaded - sessionStart - var instantSpeed float64 - if sessionElapsed.Seconds() > 0 && sessionDownloaded > 0 { - instantSpeed = float64(sessionDownloaded) / sessionElapsed.Seconds() - } - - lastSpeed := lastSpeeds[cfg.ID] - var currentSpeed float64 - if lastSpeed == 0 { - currentSpeed = instantSpeed - } else { - currentSpeed = alpha*instantSpeed + (1-alpha)*lastSpeed - } - lastSpeeds[cfg.ID] = currentSpeed - - // Create Message - msg := events.ProgressMsg{ - DownloadID: cfg.ID, - Downloaded: downloaded, - Total: total, - Speed: currentSpeed, - Elapsed: totalElapsed, - ActiveConnections: int(connections), - RateLimited: cfg.State.RateLimited.Load(), - } - - // Chunk snapshots are expensive due to bitmap/progress copies. - // Send them at a lower cadence than scalar progress fields. - if time.Since(lastChunkSnapshot[cfg.ID]) >= 500*time.Millisecond { - bitmap, width, _, chunkSize, chunkProgress := cfg.State.GetBitmapSnapshot(true) - if width > 0 && len(bitmap) > 0 { - msg.ChunkBitmap = bitmap - msg.BitmapWidth = width - msg.ActualChunkSize = chunkSize - msg.ChunkProgress = chunkProgress - lastChunkSnapshot[cfg.ID] = time.Now() - } - } - - batch = append(batch, msg) - } - - // Send batch to InputCh (non-blocking) if not empty - if len(batch) > 0 { - select { - case <-s.ctx.Done(): - return - case s.InputCh <- batch: - default: - } - } - } -} - -func (s *LocalDownloadService) getSpeedEmaAlpha() float64 { - s.settingsMu.RLock() - settings := s.settings - s.settingsMu.RUnlock() - - if settings == nil { - return SpeedSmoothingAlpha - } - - alpha := config.Resolve[float64](settings.Performance.SpeedEmaAlpha) - if alpha <= 0 || alpha > 1 { - return SpeedSmoothingAlpha - } - - return alpha -} - -// StreamEvents returns a channel that receives real-time download events. -func (s *LocalDownloadService) StreamEvents(ctx context.Context) (<-chan interface{}, func(), error) { - if ctx == nil { - ctx = context.Background() - } - outCh := make(chan interface{}, 99) - inCh := make(chan interface{}) - stopCh := make(chan struct{}) - - go func() { - defer close(outCh) - for { - select { - case <-stopCh: - return - case <-ctx.Done(): - return - case msg, ok := <-inCh: - if !ok { - return - } - select { - case outCh <- msg: - case <-stopCh: - return - case <-ctx.Done(): - return - } - } - } - }() - - s.listenerMu.Lock() - s.listeners = append(s.listeners, inCh) - s.listenerMu.Unlock() - - var once sync.Once - cleanup := func() { - once.Do(func() { - close(stopCh) - s.listenerMu.Lock() - for i, listener := range s.listeners { - if listener == inCh { - s.listeners = append(s.listeners[:i], s.listeners[i+1:]...) - break - } - } - s.listenerMu.Unlock() - }) - } - - // Callers own listener lifetime; service shutdown closes listeners after the - // broadcaster drains InputCh so lifecycle persistence can observe final events. - go func() { - select { - case <-ctx.Done(): - cleanup() - case <-stopCh: - } - }() - - return outCh, cleanup, nil -} - -// Publish emits an event into the service's event stream. -func (s *LocalDownloadService) Publish(msg interface{}) error { - if s.InputCh == nil { - return fmt.Errorf("input channel not initialized") - } - select { - case s.InputCh <- msg: - return nil - case <-time.After(1 * time.Second): - return fmt.Errorf("event publish timeout") - } -} - -// Shutdown stops the service. -func (s *LocalDownloadService) Shutdown() error { - s.shutdownOnce.Do(func() { - if s.reportTicker != nil { - s.reportTicker.Stop() - } - if s.Pool != nil { - s.Pool.GracefulShutdown() - } - - // Stop listeners and broadcaster - s.cancel() - s.reportWG.Wait() - - // Close input channel to stop broadcaster - if s.InputCh != nil { - close(s.InputCh) - } - s.broadcastWG.Wait() - }) - return s.shutdownErr -} - -// List returns the status of all active and completed downloads. -func (s *LocalDownloadService) List() ([]types.DownloadStatus, error) { - var statuses []types.DownloadStatus - - // 1. Get active downloads from pool - if s.Pool != nil { - activeConfigs := s.Pool.GetAll() - for _, cfg := range activeConfigs { - statusStr := "downloading" - if st := s.Pool.GetStatus(cfg.ID); st != nil { - statusStr = st.Status - } - status := types.DownloadStatus{ - ID: cfg.ID, - URL: cfg.URL, - Filename: cfg.Filename, - Status: statusStr, - RateLimit: cfg.RateLimitBps, - RateLimitSet: cfg.RateLimitSet, - } - - if cfg.State != nil { - // Calculate progress and speed (thread-safe) - downloaded, totalSize, _, sessionElapsed, connections, sessionStart := cfg.State.GetProgress() - - status.TotalSize = totalSize - status.Downloaded = downloaded - if dp := cfg.State.GetDestPath(); dp != "" { - status.DestPath = dp - } - - if status.TotalSize > 0 { - status.Progress = float64(status.Downloaded) * 100 / float64(status.TotalSize) - } - - // Get active connections count - status.Connections = int(connections) - - // Update status based on state - if cfg.State.IsPausing() { - status.Status = "pausing" - } else if cfg.State.IsPaused() { - status.Status = "paused" - } else if cfg.State.Done.Load() { - status.Status = "completed" - } - - // Calculate speed from progress only while actively downloading. - if status.Status == "downloading" { - sessionDownloaded := downloaded - sessionStart - if sessionElapsed.Seconds() > 0 && sessionDownloaded > 0 { - status.Speed = float64(sessionDownloaded) / sessionElapsed.Seconds() - - // Calculate ETA (seconds remaining) - remaining := status.TotalSize - status.Downloaded - if remaining > 0 && status.Speed > 0 { - status.ETA = int64(float64(remaining) / status.Speed) - } - } - } - } - - statuses = append(statuses, status) - } - } - - // 2. Fetch from database for history/paused/completed - dbDownloads, err := state.ListAllDownloads() - if err == nil { - // Create a map of existing IDs to avoid duplicates - existingIDs := make(map[string]bool) - for _, s := range statuses { - existingIDs[s.ID] = true - } - - for _, d := range dbDownloads { - // Skip if already present (active) - if existingIDs[d.ID] { - continue - } - - var progress float64 - if d.TotalSize > 0 { - progress = float64(d.Downloaded) * 100 / float64(d.TotalSize) - } else if d.Status == "completed" { - progress = 100.0 - } - - statuses = append(statuses, types.DownloadStatus{ - ID: d.ID, - URL: d.URL, - Filename: d.Filename, - DestPath: d.DestPath, - Status: d.Status, - TotalSize: d.TotalSize, - Downloaded: d.Downloaded, - Progress: progress, - Speed: completedSpeedBps(d), - Connections: 0, - TimeTaken: d.TimeTaken, - AvgSpeed: d.AvgSpeed, - RateLimit: d.RateLimit, - RateLimitSet: d.RateLimitSet, - }) - } - } - - return statuses, nil -} - -// Add queues a new download on the local pool without TUI confirmation. -func (s *LocalDownloadService) Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - return s.add(url, path, filename, mirrors, headers, "", isExplicitCategory, workers, minChunkSize, totalSize, supportsRange) -} - -// AddWithID queues a new download using a caller-provided id when non-empty. -func (s *LocalDownloadService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - // Remote or RPC-driven calls use preset IDs and should bypass interactive category routing. - return s.add(url, path, filename, mirrors, headers, id, false, workers, minChunkSize, totalSize, supportsRange) -} - -func (s *LocalDownloadService) add(url string, path string, filename string, mirrors []string, headers map[string]string, requestedID string, isExplicitCategory bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - if s.Pool == nil { - return "", types.ErrPoolNotInit - } - - s.settingsMu.RLock() - settings := s.settings - s.settingsMu.RUnlock() - - outPath := path - if outPath == "" { - if config.Resolve[string](settings.General.DefaultDownloadDir) != "" { - outPath = config.Resolve[string](settings.General.DefaultDownloadDir) - } else { - outPath = "." - } - } - outPath = utils.EnsureAbsPath(outPath) - - id := strings.TrimSpace(requestedID) - if id == "" { - id = uuid.New().String() - } - if st := s.Pool.GetStatus(id); st != nil { - return "", types.ErrIDExists - } - if entry, err := state.GetDownload(id); err != nil { - return "", fmt.Errorf("failed to query download state: %w", err) - } else if entry != nil { - return "", types.ErrIDExists - } - - state := types.NewProgressState(id, 0) - state.DestPath = filepath.Join(outPath, filename) // Best guess until download starts - - runtime := settings.ToRuntimeConfig() - if workers > 0 { - maxConns := runtime.GetMaxConnectionsPerDownload() - if workers > maxConns { - workers = maxConns - } - runtime.Workers = workers - } - if minChunkSize > 0 { - runtime.MinChunkSize = minChunkSize - } - - cfg := types.DownloadConfig{ - URL: url, - Mirrors: mirrors, - OutputPath: outPath, - ID: id, - Filename: filename, // If empty, will be auto-detected - ProgressCh: s.InputCh, - State: state, - Runtime: runtime, - Headers: headers, - IsExplicitCategory: isExplicitCategory, - TotalSize: totalSize, - SupportsRange: supportsRange, - RateLimitBps: runtime.DefaultDownloadRateLimitBps, - } - - s.Pool.Add(cfg) - - return id, nil -} - -// Pause pauses an active download. -func (s *LocalDownloadService) Pause(id string) error { - s.lifecycleHooksMu.RLock() - fn := s.lifecycleHooks.Pause - s.lifecycleHooksMu.RUnlock() - if fn != nil { - return fn(id) - } - return fmt.Errorf("PauseFunc not initialized") -} - -// Resume resumes a paused download. -func (s *LocalDownloadService) Resume(id string) error { - s.lifecycleHooksMu.RLock() - fn := s.lifecycleHooks.Resume - s.lifecycleHooksMu.RUnlock() - if fn != nil { - return fn(id) - } - return fmt.Errorf("ResumeFunc not initialized") -} - -// ResumeBatch resumes multiple paused downloads efficiently. -func (s *LocalDownloadService) ResumeBatch(ids []string) []error { - s.lifecycleHooksMu.RLock() - fn := s.lifecycleHooks.ResumeBatch - s.lifecycleHooksMu.RUnlock() - if fn != nil { - return fn(ids) - } - errs := make([]error, len(ids)) - for i := range errs { - errs[i] = fmt.Errorf("ResumeBatchFunc not initialized") - } - return errs -} - -// SetLifecycleHooks wires the processing layer into the service so -// pause/resume/cancel/updateURL calls are routed through the lifecycle manager. -func (s *LocalDownloadService) SetLifecycleHooks(hooks LifecycleHooks) { - s.lifecycleHooksMu.Lock() - s.lifecycleHooks = hooks - s.lifecycleHooksMu.Unlock() -} - -// UpdateURL updates the URL of a paused or errored download -func (s *LocalDownloadService) UpdateURL(id string, newURL string) error { - s.lifecycleHooksMu.RLock() - fn := s.lifecycleHooks.UpdateURL - s.lifecycleHooksMu.RUnlock() - if fn != nil { - return fn(id, newURL) - } - // Fallback: update pool in-memory only (no DB persistence) - if s.Pool == nil { - return types.ErrPoolNotInit - } - return s.Pool.UpdateURL(id, newURL) -} - -// Delete cancels and removes a download. -func (s *LocalDownloadService) Delete(id string) error { - s.lifecycleHooksMu.RLock() - fn := s.lifecycleHooks.Cancel - s.lifecycleHooksMu.RUnlock() - if fn != nil { - return fn(id) - } - // Fallback when lifecycle hooks not wired (e.g. tests) - if s.Pool == nil { - return types.ErrPoolNotInit - } - s.Pool.Cancel(id) - if entry, err := state.GetDownload(id); err == nil && entry != nil { - if s.InputCh != nil { - s.InputCh <- events.DownloadRemovedMsg{ - DownloadID: id, - Filename: entry.Filename, - DestPath: entry.DestPath, - Completed: entry.Status == "completed", - } - } - } - return nil -} - -// Purge cancels and removes a download, and deletes its files from disk. -func (s *LocalDownloadService) Purge(id string) error { - destPath := "" - - // Get status before deleting so we know where the file is - status, err := s.GetStatus(id) - if err == nil && status != nil { - destPath = filepath.Clean(status.DestPath) - } else { - // Fallback to history - history, err := s.History() - if err == nil { - for _, entry := range history { - if entry.ID == id { - destPath = filepath.Clean(entry.DestPath) - break - } - } - } - } - - // Delete from engine/db - if err := s.Delete(id); err != nil { - return err - } - - // Delete files if we found a path - if destPath != "" && destPath != "." { - var errs []string - if err := utils.RemoveFile(destPath); err != nil && !os.IsNotExist(err) { - errs = append(errs, err.Error()) - } - if err := utils.RemoveFile(destPath + types.IncompleteSuffix); err != nil && !os.IsNotExist(err) { - errs = append(errs, err.Error()) - } - if len(errs) > 0 { - return fmt.Errorf("failed to delete files: %s", strings.Join(errs, ", ")) - } - } - return nil -} - -// GetStatus returns a status for a single download by id. -func (s *LocalDownloadService) GetStatus(id string) (*types.DownloadStatus, error) { - if id == "" { - return nil, fmt.Errorf("missing id") - } - - // 1. Check active pool - if s.Pool != nil { - status := s.Pool.GetStatus(id) - if status != nil { - return status, nil - } - } - - // 2. Fallback to DB - entry, err := state.GetDownload(id) - if err == nil && entry != nil { - var progress float64 - if entry.TotalSize > 0 { - progress = float64(entry.Downloaded) * 100 / float64(entry.TotalSize) - } else if entry.Status == "completed" { - progress = 100.0 - } - - status := types.DownloadStatus{ - ID: entry.ID, - URL: entry.URL, - Filename: entry.Filename, - DestPath: entry.DestPath, - TotalSize: entry.TotalSize, - Downloaded: entry.Downloaded, - Progress: progress, - Speed: completedSpeedBps(*entry), - Status: entry.Status, - TimeTaken: entry.TimeTaken, - AvgSpeed: entry.AvgSpeed, - RateLimit: entry.RateLimit, - RateLimitSet: entry.RateLimitSet, - } - return &status, nil - } - - return nil, types.ErrNotFound -} - -// History returns completed downloads -func (s *LocalDownloadService) History() ([]types.DownloadEntry, error) { - // For local service, we can directly access the state DB - return state.LoadCompletedDownloads() -} - -func (s *LocalDownloadService) ClearCompleted() (int64, error) { - return state.RemoveCompletedDownloads() -} - -func (s *LocalDownloadService) ClearFailed() (int64, error) { - return state.RemoveFailedDownloads() -} - -// SetRateLimit sets the speed limit for a specific download -func (s *LocalDownloadService) SetRateLimit(id string, rate int64) error { - if rate < 0 { - return fmt.Errorf("rate limit must be non-negative") - } - if s.Pool == nil { - return types.ErrPoolNotInit - } - - entry, err := state.GetDownload(id) - if err != nil && !errors.Is(err, types.ErrNotFound) { - return err - } - - poolStatus := s.Pool.GetStatus(id) - if poolStatus == nil && (entry == nil || entry.Status == "completed") { - return fmt.Errorf("%w: %s", types.ErrNotFound, id) - } - - err = state.UpdateRateLimit(id, rate) - if err != nil && !errors.Is(err, types.ErrNotFound) { - return err - } - - foundInPool := s.Pool.SetDownloadRateLimit(id, rate) - if err != nil && !foundInPool { - return fmt.Errorf("%w: %s", types.ErrNotFound, id) - } else if err != nil && foundInPool { - utils.Debug("SetRateLimit: download %s not found in DB (unpaused) but active in pool; state will persist on pause", id) - } - return nil -} - -// ClearRateLimit clears a specific download's speed limit override. -func (s *LocalDownloadService) ClearRateLimit(id string) error { - if s.Pool == nil { - return types.ErrPoolNotInit - } - - entry, err := state.GetDownload(id) - if err != nil && !errors.Is(err, types.ErrNotFound) { - return err - } - - poolStatus := s.Pool.GetStatus(id) - if poolStatus == nil && (entry == nil || entry.Status == "completed") { - return fmt.Errorf("%w: %s", types.ErrNotFound, id) - } - - err = state.ClearRateLimit(id) - if err != nil && !errors.Is(err, types.ErrNotFound) { - return err - } - - foundInPool := s.Pool.ClearDownloadRateLimit(id) - if err != nil && !foundInPool { - return fmt.Errorf("%w: %s", types.ErrNotFound, id) - } else if err != nil && foundInPool { - utils.Debug("ClearRateLimit: download %s not found in DB (unpaused) but active in pool; state will persist on pause", id) - } - return nil -} - -// SetGlobalRateLimit sets the global speed limit for the local service. -func (s *LocalDownloadService) SetGlobalRateLimit(rate int64) error { - if rate < 0 { - return fmt.Errorf("rate limit must be non-negative") - } - if s.Pool == nil { - return types.ErrPoolNotInit - } - - s.settingsMu.Lock() - if s.settings == nil { - s.settings = config.DefaultSettings() - } - if s.settings.Network.GlobalRateLimit == nil { - s.settings.Network.GlobalRateLimit = config.DefaultSettings().Network.GlobalRateLimit - } - oldValue := s.settings.Network.GlobalRateLimit.Value - s.settings.Network.GlobalRateLimit.Value = utils.FormatRateLimit(rate) - if err := config.SaveSettings(s.settings); err != nil { - s.settings.Network.GlobalRateLimit.Value = oldValue - s.settingsMu.Unlock() - return err - } - s.settingsMu.Unlock() - - s.Pool.SetGlobalRateLimit(rate) - - return nil -} - -// SetDefaultRateLimit sets the inherited default per-download speed limit. -func (s *LocalDownloadService) SetDefaultRateLimit(rate int64) error { - if rate < 0 { - return fmt.Errorf("rate limit must be non-negative") - } - if s.Pool == nil { - return types.ErrPoolNotInit - } - - s.settingsMu.Lock() - if s.settings == nil { - s.settings = config.DefaultSettings() - } - if s.settings.Network.DefaultDownloadRateLimit == nil { - s.settings.Network.DefaultDownloadRateLimit = config.DefaultSettings().Network.DefaultDownloadRateLimit - } - oldValue := s.settings.Network.DefaultDownloadRateLimit.Value - s.settings.Network.DefaultDownloadRateLimit.Value = utils.FormatRateLimit(rate) - if err := config.SaveSettings(s.settings); err != nil { - s.settings.Network.DefaultDownloadRateLimit.Value = oldValue - s.settingsMu.Unlock() - return err - } - s.settingsMu.Unlock() - - s.Pool.SetDefaultDownloadRateLimit(rate) - - // Sync the new default rate to the DB for all downloads that inherit it. - if configs := s.Pool.GetAll(); configs != nil { - var dbErrs []string - for _, cfg := range configs { - if !cfg.RateLimitSet { - if err := state.UpdateDefaultRateLimit(cfg.ID, rate); err != nil { - dbErrs = append(dbErrs, fmt.Sprintf("%s: %v", cfg.ID, err)) - } - } - } - if len(dbErrs) > 0 { - return fmt.Errorf("failed to update default rate limit in DB for some downloads: %s", strings.Join(dbErrs, "; ")) - } - } - - return nil -} diff --git a/internal/core/local_service_override_test.go b/internal/core/local_service_override_test.go deleted file mode 100644 index 51fdf290a..000000000 --- a/internal/core/local_service_override_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package core - -import ( - "testing" - - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/utils" -) - -func findConfigByID(pool *download.WorkerPool, id string) *types.DownloadConfig { - for _, cfg := range pool.GetAll() { - if cfg.ID == id { - return &cfg - } - } - return nil -} - -func TestAdd_PerTaskOverride(t *testing.T) { - tests := []struct { - name string - workers int - minChunkSize int64 - wantWorkers int - wantMinChunk int64 - checkClamped bool - }{ - { - name: "zero/defaults", - workers: 0, - minChunkSize: 0, - wantWorkers: 0, - wantMinChunk: types.MinChunk, - }, - { - name: "workers-only", - workers: 16, - minChunkSize: 0, - wantWorkers: 16, - wantMinChunk: types.MinChunk, - }, - { - name: "minChunk-only", - workers: 0, - minChunkSize: 10 * utils.MiB, - wantWorkers: 0, - wantMinChunk: 10 * utils.MiB, - }, - { - name: "workers-clamped", - workers: 64, - minChunkSize: 0, - checkClamped: true, - wantMinChunk: types.MinChunk, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ch := make(chan interface{}, 8) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - - outputDir := t.TempDir() - id, err := svc.Add("https://example.com/file.bin", outputDir, "file.bin", nil, nil, false, tt.workers, tt.minChunkSize, 0, false) - if err != nil { - t.Fatalf("Add failed: %v", err) - } - - cfg := findConfigByID(pool, id) - if cfg == nil { - t.Fatal("expected config in pool") - } - - if tt.checkClamped { - maxConns := cfg.Runtime.GetMaxConnectionsPerDownload() - if cfg.Runtime.Workers != maxConns { - t.Fatalf("expected Runtime.Workers=%d (clamped to MaxConns), got %d", maxConns, cfg.Runtime.Workers) - } - if cfg.Runtime.MinChunkSize != tt.wantMinChunk { - t.Fatalf("expected Runtime.MinChunkSize=%d (default), got %d", tt.wantMinChunk, cfg.Runtime.MinChunkSize) - } - return - } - - if cfg.Runtime.Workers != tt.wantWorkers { - t.Fatalf("expected Runtime.Workers=%d, got %d", tt.wantWorkers, cfg.Runtime.Workers) - } - if cfg.Runtime.MinChunkSize != tt.wantMinChunk { - t.Fatalf("expected Runtime.MinChunkSize=%d, got %d", tt.wantMinChunk, cfg.Runtime.MinChunkSize) - } - }) - } -} diff --git a/internal/core/local_service_test.go b/internal/core/local_service_test.go deleted file mode 100644 index 65bde8882..000000000 --- a/internal/core/local_service_test.go +++ /dev/null @@ -1,719 +0,0 @@ -package core - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/testutil" -) - -func TestLocalDownloadService_Delete_DBOnlyBroadcastsRemoved(t *testing.T) { - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - ch := make(chan interface{}, 20) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - evCleanup := startEventWorkerForTest(t, svc) - defer evCleanup() - streamCh, cleanup, err := svc.StreamEvents(context.Background()) - if err != nil { - t.Fatalf("failed to stream events: %v", err) - } - defer cleanup() - - id := "delete-db-only-id" - url := "https://example.com/file.bin" - destPath := filepath.Join(tempDir, "file.bin") - incompletePath := destPath + types.IncompleteSuffix - - if err := os.WriteFile(incompletePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create partial file: %v", err) - } - - if err := state.SaveState(url, destPath, &types.DownloadState{ - ID: id, - URL: url, - DestPath: destPath, - Filename: "file.bin", - TotalSize: 1000, - Downloaded: 200, - Tasks: []types.Task{ - {Offset: 200, Length: 800}, - }, - }); err != nil { - t.Fatalf("failed to seed state: %v", err) - } - _ = state.AddToMasterList(types.DownloadEntry{ID: id, URL: url, DestPath: destPath, Status: "paused"}) - - if err := svc.Delete(id); err != nil { - t.Fatalf("delete failed: %v", err) - } - - gotRemoved := false - deadline := time.After(500 * time.Millisecond) - for !gotRemoved { - select { - case msg := <-streamCh: - if m, ok := msg.(events.DownloadRemovedMsg); ok && m.DownloadID == id { - gotRemoved = true - } - case <-deadline: - t.Fatal("expected DownloadRemovedMsg for deleted DB-only download") - } - } - - // Wait briefly for event worker to actually apply the DB deletion after emitting the event - deletionDeadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deletionDeadline) { - entry, _ := state.GetDownload(id) - if entry == nil { - return // Success, it is gone - } - time.Sleep(10 * time.Millisecond) - } - - entry, err := state.GetDownload(id) - if err != nil { - t.Fatalf("failed querying deleted entry: %v", err) - } - if entry != nil { - t.Fatalf("expected entry to be removed, got %+v", entry) - } -} - -func TestLocalDownloadService_Delete_ActiveWithoutDB_RemovesPartialFile(t *testing.T) { - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - ch := make(chan interface{}, 100) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - evCleanup := startEventWorkerForTest(t, svc) - defer evCleanup() - - server := testutil.NewStreamingMockServerT(t, - 200*1024*1024, - testutil.WithRangeSupport(true), - testutil.WithLatency(8*time.Millisecond), - ) - defer server.Close() - - outputDir := t.TempDir() - const filename = "active-delete.bin" - if f, err := os.Create(filepath.Join(outputDir, filename) + ".surge"); err == nil { - _ = f.Close() - } - id, err := svc.Add(server.URL(), outputDir, filename, nil, nil, false, 0, 0, 0, false) - if err != nil { - t.Fatalf("failed to add download: %v", err) - } - - // Wait until the download is actively running and exposes its resolved destination path. - deadline := time.Now().Add(8 * time.Second) - var st *types.DownloadStatus - var runtimeDestPath string - for time.Now().Before(deadline) { - st, _ = svc.GetStatus(id) - if st != nil && st.DestPath != "" && st.Status == "downloading" { - runtimeDestPath = st.DestPath - break - } - time.Sleep(25 * time.Millisecond) - } - if runtimeDestPath == "" { - t.Fatalf("expected active runtime status with destination path before delete, got: %+v", st) - } - incompletePath := runtimeDestPath + types.IncompleteSuffix - - // Ensure the partial file exists before delete to validate cleanup logic deterministically. - if _, err := os.Stat(incompletePath); os.IsNotExist(err) { - if err := os.WriteFile(incompletePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create partial file before delete: %v", err) - } - } else if err != nil { - t.Fatalf("failed to stat partial file before delete: %v", err) - } - - // Simulate delete-before-persist path: no DB entry available. - _ = state.RemoveFromMasterList(id) - - if err := svc.Delete(id); err != nil { - t.Fatalf("delete failed: %v", err) - } - - deadline = time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if _, err := os.Stat(incompletePath); os.IsNotExist(err) { - return - } - time.Sleep(25 * time.Millisecond) - } - - if _, err := os.Stat(incompletePath); !os.IsNotExist(err) { - t.Fatalf("expected partial file to be deleted, stat err: %v", err) - } -} - -func TestLocalDownloadService_Shutdown_Idempotent(t *testing.T) { - ch := make(chan interface{}, 1) - svc := NewLocalDownloadServiceWithInput(nil, ch) - - if err := svc.Shutdown(); err != nil { - t.Fatalf("first shutdown failed: %v", err) - } - - select { - case _, ok := <-ch: - if ok { - t.Fatal("expected input channel to be closed after shutdown") - } - case <-time.After(500 * time.Millisecond): - t.Fatal("timed out waiting for input channel to close") - } - - if err := svc.Shutdown(); err != nil { - t.Fatalf("second shutdown failed: %v", err) - } -} - -func TestLocalDownloadService_Shutdown_WaitsForBroadcastDrain(t *testing.T) { - ch := make(chan interface{}, 200) - svc := NewLocalDownloadServiceWithInput(nil, ch) - - streamCh, cleanup, err := svc.StreamEvents(context.Background()) - if err != nil { - t.Fatalf("failed to stream events: %v", err) - } - defer cleanup() - - for range 101 { - if err := svc.Publish(events.SystemLogMsg{Message: "queued"}); err != nil { - t.Fatalf("failed to publish event: %v", err) - } - } - - done := make(chan error, 1) - go func() { - done <- svc.Shutdown() - }() - - select { - case err := <-done: - t.Fatalf("shutdown returned before broadcaster drained listener backlog: %v", err) - case <-time.After(50 * time.Millisecond): - } - - select { - case <-streamCh: - case <-time.After(500 * time.Millisecond): - t.Fatal("timed out draining listener backlog") - } - - select { - case err := <-done: - if err != nil { - t.Fatalf("shutdown failed: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("shutdown did not finish after broadcaster unblocked") - } -} - -func TestLocalDownloadService_StreamEvents_DrainAfterCancel(t *testing.T) { - ch := make(chan interface{}, 4) - svc := NewLocalDownloadServiceWithInput(nil, ch) - - streamCh, cleanup, err := svc.StreamEvents(context.Background()) - if err != nil { - t.Fatalf("failed to stream events: %v", err) - } - defer cleanup() - - svc.cancel() - - select { - case _, ok := <-streamCh: - if !ok { - t.Fatal("listener closed before input drain completed") - } - t.Fatal("unexpected event while verifying listener lifetime") - case <-time.After(50 * time.Millisecond): - } - - close(ch) - - select { - case _, ok := <-streamCh: - if ok { - t.Fatal("expected listener to close after input drain") - } - case <-time.After(500 * time.Millisecond): - t.Fatal("timed out waiting for listener to close after input drain") - } -} - -func TestLocalDownloadService_AddWithID_UsesProvidedID(t *testing.T) { - ch := make(chan interface{}, 8) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - - requestID := "provided-id-001" - outputDir := t.TempDir() - gotID, err := svc.AddWithID("https://example.com/file.bin", outputDir, "file.bin", nil, nil, requestID, 0, 0, 0, false) - if err != nil { - t.Fatalf("AddWithID failed: %v", err) - } - if gotID != requestID { - t.Fatalf("AddWithID returned %q, want %q", gotID, requestID) - } - - if st := pool.GetStatus(requestID); st == nil { - t.Fatalf("expected pool status for request id %q", requestID) - } -} - -func TestLocalDownloadService_Shutdown_PersistsPausedState(t *testing.T) { - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - ch := make(chan interface{}, 100) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - evWait := startEventWorkerForTest(t, svc) - - server := testutil.NewStreamingMockServerT(t, - 500*1024*1024, - testutil.WithRangeSupport(true), - testutil.WithLatency(10*time.Millisecond), - ) - defer server.Close() - - outputDir := t.TempDir() - const filename = "persist.bin" - const fileSize = 500 * 1024 * 1024 - if f, err := os.Create(filepath.Join(outputDir, filename) + ".surge"); err == nil { - _ = f.Close() - } - id, err := svc.Add(server.URL(), outputDir, filename, nil, nil, false, 0, 0, fileSize, true) - if err != nil { - t.Fatalf("failed to add download: %v", err) - } - - deadline := time.Now().Add(8 * time.Second) - progressed := false - for time.Now().Before(deadline) { - st, err := svc.GetStatus(id) - if err == nil && st != nil && st.Downloaded > 0 { - progressed = true - break - } - time.Sleep(50 * time.Millisecond) - } - if !progressed { - t.Fatal("download did not make progress before shutdown") - } - - if err := svc.Shutdown(); err != nil { - t.Fatalf("shutdown failed: %v", err) - } - // Wait for event worker to drain all buffered events and finish DB writes - evWait() - - deadline = time.Now().Add(2 * time.Second) - for { - entry, err := state.GetDownload(id) - if err != nil { - errStr := strings.ToLower(err.Error()) - if strings.Contains(errStr, "locked") || strings.Contains(errStr, "busy") || strings.Contains(errStr, "process cannot access") { - if time.Now().After(deadline) { - t.Fatalf("failed to fetch persisted download before timeout: %v", err) - } - time.Sleep(20 * time.Millisecond) - continue - } - t.Fatalf("failed to fetch persisted download: %v", err) - } - - if entry == nil { - t.Fatal("expected persisted download entry after shutdown") - return - } - if entry.Status != "paused" { - t.Fatalf("status = %q, want paused", entry.Status) - } - if entry.Downloaded == 0 { - t.Fatal("expected persisted paused download to have non-zero progress") - } - break - } - - statuses, err := svc.List() - if err != nil { - t.Fatalf("failed to list downloads after shutdown: %v", err) - } - foundInList := false - for _, st := range statuses { - if st.ID == id { - foundInList = true - if st.Status != "paused" && st.Status != "pausing" { - t.Fatalf("list status = %q, want paused/pausing", st.Status) - } - break - } - } - if !foundInList { - t.Fatal("expected paused download to remain visible in list after shutdown") - } - - destPath := filepath.Join(outputDir, filename) - saved, err := state.LoadState(server.URL(), destPath) - if err != nil { - t.Fatalf("failed to load saved state: %v", err) - } - if saved.ID != id { - t.Fatalf("saved state id = %q, want %q", saved.ID, id) - } - if len(saved.Tasks) == 0 { - t.Fatal("expected saved state to include remaining tasks") - } -} - -func TestLocalDownloadService_BatchProgress(t *testing.T) { - // Start a local test server - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // 1. Probe request (HEAD or GET with Range: bytes=0-0) - if r.Method == "HEAD" || r.Header.Get("Range") == "bytes=0-0" { - w.Header().Set("Content-Length", "1000") - w.Header().Set("Accept-Ranges", "bytes") - w.WriteHeader(http.StatusOK) - return - } - - // 2. Download request - w.Header().Set("Content-Length", "1000") - w.WriteHeader(http.StatusOK) - - // Send some data - if _, err := w.Write(make([]byte, 500)); err != nil { - t.Errorf("failed to write data: %v", err) - } - if f, ok := w.(http.Flusher); ok { - f.Flush() - } - - // Block to keep connection open so worker stays active - time.Sleep(2 * time.Second) - })) - defer ts.Close() - - ch := make(chan interface{}, 20) - // Create temporary directory for downloads - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - - streamCh, cleanup, err := svc.StreamEvents(context.Background()) - if err != nil { - t.Fatalf("failed to stream events: %v", err) - } - defer cleanup() - - // Add download using test server URL - - if f, err := os.Create(filepath.Join(tempDir, "test-file") + ".surge"); err == nil { - _ = f.Close() - } - _, err = svc.Add(ts.URL, tempDir, "test-file", nil, nil, false, 0, 0, 0, false) - if err != nil { - t.Fatalf("failed to add download: %v", err) - } - - // Wait for a BatchProgressMsg - // We need to wait enough time for the report loop to tick (150ms) - deadline := time.After(2 * time.Second) - gotBatch := false - - for !gotBatch { - select { - case msg := <-streamCh: - if _, ok := msg.(events.BatchProgressMsg); ok { - gotBatch = true - } - case <-deadline: - t.Fatal("timeout waiting for BatchProgressMsg") - } - } -} - -func TestLocalDownloadService_ResumeRejectedWhilePausing(t *testing.T) { - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - ch := make(chan interface{}, 100) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - evCleanup := startEventWorkerForTest(t, svc) - defer evCleanup() - - server := testutil.NewStreamingMockServerT(t, - 500*1024*1024, - testutil.WithRangeSupport(true), - testutil.WithLatency(10*time.Millisecond), - ) - defer server.Close() - - outputDir := t.TempDir() - if f, err := os.Create(filepath.Join(outputDir, "resume-race.bin") + ".surge"); err == nil { - _ = f.Close() - } - id, err := svc.Add(server.URL(), outputDir, "resume-race.bin", nil, nil, false, 0, 0, 0, false) - if err != nil { - t.Fatalf("failed to add download: %v", err) - } - - // Wait until download starts moving. - deadline := time.Now().Add(6 * time.Second) - for time.Now().Before(deadline) { - st, _ := svc.GetStatus(id) - if st != nil && st.Downloaded > 0 { - break - } - time.Sleep(50 * time.Millisecond) - } - - if err := svc.Pause(id); err != nil { - t.Fatalf("pause failed: %v", err) - } - - // If pause finalized too fast on this machine, skip this race-specific assertion. - st, _ := svc.GetStatus(id) - if st == nil || st.Status != "pausing" { - t.Skip("download transitioned out of pausing before resume-race assertion") - } - - err = svc.Resume(id) - if err == nil { - t.Skip("download transitioned out of pausing before or during resume") - } else if !errors.Is(err, types.ErrPausing) { - t.Fatalf("expected ErrPausing, got %v", err) - } -} - -// --- Rate limit validation tests --- - -func TestLocalDownloadService_SetRateLimit_NegativeRate(t *testing.T) { - svc := NewLocalDownloadService(nil) - err := svc.SetRateLimit("dl-1", -1) - if err == nil { - t.Fatal("expected error for negative rate") - } - if !strings.Contains(err.Error(), "non-negative") { - t.Fatalf("expected 'non-negative' error, got: %v", err) - } -} - -func TestLocalDownloadService_SetRateLimit_ZeroPoolReturnsError(t *testing.T) { - svc := NewLocalDownloadService(nil) - err := svc.SetRateLimit("dl-1", 0) - if err == nil { - t.Fatal("expected error when pool is nil") - } -} - -func TestLocalDownloadService_SetGlobalRateLimit_NegativeRate(t *testing.T) { - svc := NewLocalDownloadService(nil) - err := svc.SetGlobalRateLimit(-1) - if err == nil { - t.Fatal("expected error for negative rate") - } - if !strings.Contains(err.Error(), "non-negative") { - t.Fatalf("expected 'non-negative' error, got: %v", err) - } -} - -func TestLocalDownloadService_SetDefaultRateLimit_NegativeRate(t *testing.T) { - svc := NewLocalDownloadService(nil) - err := svc.SetDefaultRateLimit(-1) - if err == nil { - t.Fatal("expected error for negative rate") - } - if !strings.Contains(err.Error(), "non-negative") { - t.Fatalf("expected 'non-negative' error, got: %v", err) - } -} - -func TestLocalDownloadService_SetRateLimit_UpdatesPool(t *testing.T) { - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - ch := make(chan interface{}, 10) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case <-time.After(10 * time.Second): - case <-r.Context().Done(): - } - })) - defer ts.Close() - - cfg := types.DownloadConfig{ - ID: "pool-rate-test", - URL: ts.URL, - RateLimitBps: 0, - RateLimitSet: false, - State: &types.ProgressState{}, - } - pool.Add(cfg) - - // Yield briefly to let worker pick it up - time.Sleep(50 * time.Millisecond) - - if err := svc.SetRateLimit("pool-rate-test", 3*1024*1024); err != nil { - t.Fatalf("SetRateLimit: %v", err) - } - - cfgAfter, exists := findPoolConfig(pool, "pool-rate-test") - if !exists { - t.Fatal("expected queued download to still exist") - } - if !cfgAfter.RateLimitSet { - t.Error("expected RateLimitSet to be true") - } - if cfgAfter.RateLimitBps != 3*1024*1024 { - t.Errorf("RateLimitBps = %d, want %d", cfgAfter.RateLimitBps, 3*1024*1024) - } - - // Cancel the download to unblock the hanging httptest.NewServer - _ = svc.Delete("pool-rate-test") -} - -func TestLocalDownloadService_ClearRateLimit_UpdatesPool(t *testing.T) { - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - ch := make(chan interface{}, 10) - pool := download.NewWorkerPool(ch, 1) - pool.SetDefaultDownloadRateLimit(512 * 1024) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case <-time.After(10 * time.Second): - case <-r.Context().Done(): - } - })) - defer ts.Close() - - cfg := types.DownloadConfig{ - ID: "pool-clear-rate-test", - URL: ts.URL, - RateLimitBps: 3 * 1024 * 1024, - RateLimitSet: true, - State: &types.ProgressState{}, - } - pool.Add(cfg) - - // Yield briefly to let worker pick it up - time.Sleep(50 * time.Millisecond) - - if err := svc.ClearRateLimit("pool-clear-rate-test"); err != nil { - t.Fatalf("ClearRateLimit: %v", err) - } - - cfgAfter, exists := findPoolConfig(pool, "pool-clear-rate-test") - if !exists { - t.Fatal("expected queued download to still exist") - } - if cfgAfter.RateLimitSet { - t.Error("expected RateLimitSet to be false after clear") - } - - // Cancel the download to unblock the hanging httptest.NewServer - _ = svc.Delete("pool-clear-rate-test") -} - -func TestLocalDownloadService_SetRateLimit_UnknownIDReturnsNotFound(t *testing.T) { - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - ch := make(chan interface{}, 10) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - - err := svc.SetRateLimit("missing-rate-id", 1024) - if !errors.Is(err, types.ErrNotFound) { - t.Fatalf("SetRateLimit error = %v, want ErrNotFound", err) - } - - poolStatus := pool.GetStatus("missing-rate-id") - if poolStatus != nil { - t.Fatalf("missing download unexpectedly exists in pool: %#v", poolStatus) - } -} - -func TestLocalDownloadService_ClearRateLimit_UnknownIDReturnsNotFound(t *testing.T) { - tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, fmt.Sprintf("%s-surge.db", t.Name()))) - defer state.CloseDB() - - ch := make(chan interface{}, 10) - pool := download.NewWorkerPool(ch, 1) - svc := NewLocalDownloadServiceWithInput(pool, ch) - defer func() { _ = svc.Shutdown() }() - - err := svc.ClearRateLimit("missing-rate-id") - if !errors.Is(err, types.ErrNotFound) { - t.Fatalf("ClearRateLimit error = %v, want ErrNotFound", err) - } -} - -func findPoolConfig(pool *download.WorkerPool, id string) (types.DownloadConfig, bool) { - for _, cfg := range pool.GetAll() { - if cfg.ID == id { - return cfg, true - } - } - return types.DownloadConfig{}, false -} diff --git a/internal/core/pause_resume_integration_test.go b/internal/core/pause_resume_integration_test.go deleted file mode 100644 index cea620348..000000000 --- a/internal/core/pause_resume_integration_test.go +++ /dev/null @@ -1,883 +0,0 @@ -package core - -import ( - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/testutil" -) - -func waitForDownloadStatus( - t *testing.T, - svc *LocalDownloadService, - id string, - timeout time.Duration, - predicate func(*types.DownloadStatus) bool, -) *types.DownloadStatus { - t.Helper() - deadline := time.Now().Add(timeout) - var last *types.DownloadStatus - var lastErr error - - for time.Now().Before(deadline) { - st, err := svc.GetStatus(id) - last = st - lastErr = err - if err == nil && st != nil && predicate(st) { - return st - } - time.Sleep(50 * time.Millisecond) - } - - if lastErr != nil { - t.Fatalf("timeout waiting for status for %s; last error: %v", id, lastErr) - } - if last == nil { - t.Fatalf("timeout waiting for status for %s; last status: ", id) - return nil - } - t.Fatalf( - "timeout waiting for status for %s; last status: status=%s downloaded=%d total=%d speed=%.4f conns=%d progress=%.3f", - id, - last.Status, - last.Downloaded, - last.TotalSize, - last.Speed, - last.Connections, - last.Progress, - ) - return nil -} - -func waitForSavedStateByID( - t *testing.T, - id string, - timeout time.Duration, - predicate func(*types.DownloadState) bool, -) *types.DownloadState { - t.Helper() - deadline := time.Now().Add(timeout) - var last *types.DownloadState - var lastErr error - - for time.Now().Before(deadline) { - states, err := state.LoadStates([]string{id}) - if err != nil { - lastErr = err - time.Sleep(50 * time.Millisecond) - continue - } - - s := states[id] - if s == nil { - lastErr = fmt.Errorf("download state %s not found yet", id) - time.Sleep(50 * time.Millisecond) - continue - } - - last = s - if predicate(s) { - return s - } - time.Sleep(50 * time.Millisecond) - } - - if last != nil { - t.Fatalf( - "timeout waiting for saved state; last state: downloaded=%d total=%d elapsed=%d tasks=%d", - last.Downloaded, - last.TotalSize, - last.Elapsed, - len(last.Tasks), - ) - } - t.Fatalf("timeout waiting for saved state; last error: %v", lastErr) - return nil -} - -func progressFrom(downloaded, total int64) float64 { - if total <= 0 { - return 0 - } - return float64(downloaded) * 100 / float64(total) -} - -func requireProgressClose(t *testing.T, got, want float64, label string) { - t.Helper() - const eps = 0.001 - diff := got - want - if diff < 0 { - diff = -diff - } - if diff > eps { - t.Fatalf("%s progress mismatch: got=%.6f want=%.6f (diff=%.6f)", label, got, want, diff) - } -} - -func newDeterministicStreamingServer(t *testing.T, fileSize int64) *testutil.StreamingMockServer { - t.Helper() - return testutil.NewStreamingMockServerT( - t, - fileSize, - testutil.WithRangeSupport(true), - testutil.WithLatency(10*time.Millisecond), - // Streaming server applies ByteLatency per byte. Keep this tiny so we still - // get a deterministic pause window without stretching integration test timeouts. - testutil.WithByteLatency(500*time.Nanosecond), - ) -} - -func forceSingleConnectionRuntime(svc *LocalDownloadService) { - svc.settingsMu.Lock() - defer svc.settingsMu.Unlock() - - if svc.settings == nil { - svc.settings = config.DefaultSettings() - } - - // Keep integration behavior deterministic: - // - single worker connection (no hedging/stealing overlap effects), - // - conservative health settings to avoid synthetic task cancellation. - svc.settings.Network.MaxConnectionsPerDownload.Value = 1 - svc.settings.Performance.SlowWorkerGracePeriod.Value = 60 * time.Second - svc.settings.Performance.StallTimeout.Value = 60 * time.Second -} - -func TestIntegration_PauseResume_HotPath_Aggregates(t *testing.T) { - rootDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", rootDir) - - state.CloseDB() - dbPath := filepath.Join(rootDir, fmt.Sprintf("%s-surge.db", t.Name())) - state.Configure(dbPath) - defer state.CloseDB() - - progressCh := make(chan any, 256) - pool := download.NewWorkerPool(progressCh, 1) - svc := NewLocalDownloadServiceWithInput(pool, progressCh) - forceSingleConnectionRuntime(svc) - defer func() { _ = svc.Shutdown() }() - evCleanup := startEventWorkerForTest(t, svc) - defer evCleanup() - - const fileSize = int64(96 * 1024 * 1024) - server := newDeterministicStreamingServer(t, fileSize) - defer server.Close() - - outputDir := t.TempDir() - const filename = "hot-aggregate.bin" - destPath := filepath.Join(outputDir, filename) - - if f, err := os.Create(destPath + ".surge"); err == nil { - _ = f.Close() - } - id, err := svc.Add(server.URL(), outputDir, filename, nil, nil, false, 0, 0, fileSize, true) - if err != nil { - t.Fatalf("add failed: %v", err) - } - - start := waitForDownloadStatus(t, svc, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && - st.TotalSize == fileSize && - st.Downloaded > 2*1024*1024 && - st.Speed > 0 - }) - - if start.TotalSize != fileSize { - t.Fatalf("total size mismatch before pause: got=%d want=%d", start.TotalSize, fileSize) - } - requireProgressClose(t, start.Progress, progressFrom(start.Downloaded, start.TotalSize), "before-pause") - - if err := svc.Pause(id); err != nil { - t.Fatalf("pause failed: %v", err) - } - - paused := waitForDownloadStatus(t, svc, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "paused" - }) - - if paused.Downloaded < start.Downloaded { - t.Fatalf("downloaded moved backwards on pause: before=%d paused=%d", start.Downloaded, paused.Downloaded) - } - if paused.Speed != 0 { - t.Fatalf("paused speed must be 0, got %.6f MB/s", paused.Speed) - } - if paused.Connections != 0 { - t.Fatalf("paused connections must be 0, got %d", paused.Connections) - } - requireProgressClose(t, paused.Progress, progressFrom(paused.Downloaded, paused.TotalSize), "paused") - - time.Sleep(700 * time.Millisecond) - pausedStable := waitForDownloadStatus(t, svc, id, 5*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "paused" - }) - if pausedStable.Downloaded != paused.Downloaded { - t.Fatalf("paused downloaded drifted: first=%d later=%d", paused.Downloaded, pausedStable.Downloaded) - } - if pausedStable.Speed != 0 { - t.Fatalf("paused speed drifted from 0 to %.6f", pausedStable.Speed) - } - - saved1 := waitForSavedStateByID(t, id, 25*time.Second, func(s *types.DownloadState) bool { - return s.TotalSize == fileSize && s.Downloaded > 0 && s.Elapsed > 0 && len(s.Tasks) > 0 - }) - - if saved1.Downloaded != paused.Downloaded { - t.Fatalf("saved downloaded mismatch: saved=%d status=%d", saved1.Downloaded, paused.Downloaded) - } - if saved1.DestPath != destPath { - t.Fatalf("saved dest path mismatch: got=%q want=%q", saved1.DestPath, destPath) - } - - entry1, err := state.GetDownload(id) - if err != nil { - t.Fatalf("state.GetDownload failed: %v", err) - } - // Wait for master list to catch up (SaveState writes detail then master) - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if entry1 != nil && entry1.Downloaded == saved1.Downloaded { - break - } - time.Sleep(50 * time.Millisecond) - entry1, _ = state.GetDownload(id) - } - - if entry1 == nil { - t.Fatal("missing master-list entry after pause") - return - } - if entry1.Status != "paused" { - t.Fatalf("entry status mismatch: got=%q want=paused", entry1.Status) - } - if entry1.Downloaded != saved1.Downloaded { - t.Fatalf("entry downloaded mismatch: entry=%d saved=%d", entry1.Downloaded, saved1.Downloaded) - } - diff := saved1.Elapsed - (entry1.TimeTaken * int64(time.Millisecond)) - if diff < 0 { - diff = -diff - } - if diff >= int64(time.Millisecond) { - t.Fatalf( - "elapsed persistence mismatch: saved_ns=%d entry_ms=%d diff_ns=%d", - saved1.Elapsed, - entry1.TimeTaken, - diff, - ) - } - - if err := svc.Resume(id); err != nil { - t.Fatalf("resume failed: %v", err) - } - - resumed := waitForDownloadStatus(t, svc, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && - st.TotalSize == fileSize && - st.Downloaded >= saved1.Downloaded && - st.Speed > 0 - }) - if resumed.Downloaded < saved1.Downloaded { - t.Fatalf("resumed downloaded below saved snapshot: resumed=%d saved=%d", resumed.Downloaded, saved1.Downloaded) - } - - resumedAdvanced := waitForDownloadStatus(t, svc, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && - st.Downloaded > saved1.Downloaded+1024*1024 && - st.Speed > 0 - }) - if resumedAdvanced.Downloaded <= saved1.Downloaded { - t.Fatalf("resume made no forward progress: resumed=%d saved=%d", resumedAdvanced.Downloaded, saved1.Downloaded) - } - requireProgressClose(t, resumedAdvanced.Progress, progressFrom(resumedAdvanced.Downloaded, resumedAdvanced.TotalSize), "resumed-advanced") - - if err := svc.Pause(id); err != nil { - t.Fatalf("second pause failed: %v", err) - } - - paused2 := waitForDownloadStatus(t, svc, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "paused" - }) - if paused2.Speed != 0 { - t.Fatalf("second paused speed must be 0, got %.6f", paused2.Speed) - } - - saved2 := waitForSavedStateByID(t, id, 25*time.Second, func(s *types.DownloadState) bool { - return s.TotalSize == fileSize && len(s.Tasks) > 0 && s.Downloaded == paused2.Downloaded - }) - - if saved2.Downloaded != paused2.Downloaded { - t.Fatalf("second saved downloaded mismatch: saved=%d status=%d", saved2.Downloaded, paused2.Downloaded) - } - - // Keep the failure output concrete and useful. - t.Logf( - "hot path aggregates: first_pause(downloaded=%d elapsed_ns=%d) second_pause(downloaded=%d elapsed_ns=%d)", - saved1.Downloaded, - saved1.Elapsed, - saved2.Downloaded, - saved2.Elapsed, - ) -} - -func TestIntegration_PauseResume_ColdPath_StateContinuity(t *testing.T) { - rootDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", rootDir) - - state.CloseDB() - dbPath := filepath.Join(rootDir, fmt.Sprintf("%s-surge.db", t.Name())) - state.Configure(dbPath) - defer state.CloseDB() - - const fileSize = int64(96 * 1024 * 1024) - server := newDeterministicStreamingServer(t, fileSize) - defer server.Close() - - outputDir := t.TempDir() - const filename = "cold-continuity.bin" - destPath := filepath.Join(outputDir, filename) - - // Service instance #1: start and pause. - ch1 := make(chan any, 256) - pool1 := download.NewWorkerPool(ch1, 1) - svc1 := NewLocalDownloadServiceWithInput(pool1, ch1) - forceSingleConnectionRuntime(svc1) - evCleanup1 := startEventWorkerForTest(t, svc1) - - if f, err := os.Create(destPath + ".surge"); err == nil { - _ = f.Close() - } - id, err := svc1.Add(server.URL(), outputDir, filename, nil, nil, false, 0, 0, fileSize, true) - if err != nil { - t.Fatalf("add failed: %v", err) - } - - waitForDownloadStatus(t, svc1, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && - st.TotalSize == fileSize && - st.Downloaded > 2*1024*1024 && - st.Speed > 0 - }) - - if err := svc1.Pause(id); err != nil { - t.Fatalf("pause failed: %v", err) - } - - paused1 := waitForDownloadStatus(t, svc1, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "paused" - }) - - saved1 := waitForSavedStateByID(t, id, 25*time.Second, func(s *types.DownloadState) bool { - return s.Downloaded == paused1.Downloaded && s.Elapsed > 0 - }) - - evCleanup1() - if err := svc1.Shutdown(); err != nil { - t.Fatalf("svc1 shutdown failed: %v", err) - } - - // Service instance #2: cold resume from DB. - ch2 := make(chan any, 256) - pool2 := download.NewWorkerPool(ch2, 1) - svc2 := NewLocalDownloadServiceWithInput(pool2, ch2) - forceSingleConnectionRuntime(svc2) - defer func() { _ = svc2.Shutdown() }() - evCleanup2 := startEventWorkerForTest(t, svc2) - defer evCleanup2() - - if err := svc2.Resume(id); err != nil { - t.Fatalf("cold resume failed: %v", err) - } - - resumed := waitForDownloadStatus(t, svc2, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && - st.TotalSize == fileSize && - st.Downloaded >= saved1.Downloaded && - st.Speed > 0 - }) - - if resumed.Downloaded < saved1.Downloaded { - t.Fatalf("cold resume lost downloaded bytes: resumed=%d saved=%d", resumed.Downloaded, saved1.Downloaded) - } - requireProgressClose(t, resumed.Progress, progressFrom(resumed.Downloaded, resumed.TotalSize), "cold-resume") - - waitForDownloadStatus(t, svc2, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && - st.Downloaded > saved1.Downloaded+1024*1024 && - st.Speed > 0 - }) - - if err := svc2.Pause(id); err != nil { - t.Fatalf("second pause after cold resume failed: %v", err) - } - - paused2 := waitForDownloadStatus(t, svc2, id, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "paused" - }) - if paused2.Speed != 0 { - t.Fatalf("paused speed after cold resume must be 0, got %.6f", paused2.Speed) - } - - saved2 := waitForSavedStateByID(t, id, 25*time.Second, func(s *types.DownloadState) bool { - return s.Downloaded == paused2.Downloaded && s.Elapsed >= saved1.Elapsed - }) - - if saved2.DestPath != destPath { - t.Fatalf("dest path changed across cold resume: got=%q want=%q", saved2.DestPath, destPath) - } - - // We can remove the sleep since waitForSavedStateByID now waits for the exact condition. - finalSaved, _ := state.LoadStates([]string{id}) - savedFinal := finalSaved[id] - - if savedFinal == nil { - t.Fatalf("missing final saved state") - return - } - - if savedFinal.Downloaded != paused2.Downloaded { - t.Fatalf("saved downloaded mismatch after cold resume: saved=%d status=%d", savedFinal.Downloaded, paused2.Downloaded) - } - - entry2, err := state.GetDownload(id) - if err != nil { - t.Fatalf("state.GetDownload failed: %v", err) - } - - // Wait for master list to catch up (SaveState writes detail then master) - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if entry2 != nil && entry2.Downloaded == savedFinal.Downloaded { - break - } - time.Sleep(50 * time.Millisecond) - entry2, _ = state.GetDownload(id) - } - - if entry2 == nil { - t.Fatal("missing entry after second pause") - return - } - if entry2.Status != "paused" { - t.Fatalf("entry status mismatch after cold resume: got=%q", entry2.Status) - } - if entry2.Downloaded != savedFinal.Downloaded { - t.Fatalf("entry downloaded mismatch after cold resume: entry=%d saved=%d", entry2.Downloaded, savedFinal.Downloaded) - } - diff2 := savedFinal.Elapsed - (entry2.TimeTaken * int64(time.Millisecond)) - if diff2 < 0 { - diff2 = -diff2 - } - if diff2 >= int64(time.Millisecond) { - t.Fatalf( - "elapsed mismatch after cold resume: saved_ns=%d entry_ms=%d diff_ns=%d", - savedFinal.Elapsed, - entry2.TimeTaken, - diff2, - ) - } - - t.Logf( - "cold path continuity: first_pause(downloaded=%d elapsed_ns=%d) second_pause(downloaded=%d elapsed_ns=%d)", - saved1.Downloaded, - saved1.Elapsed, - saved2.Downloaded, - saved2.Elapsed, - ) -} - -func TestIntegration_PauseResume_ResumeBatchRejectsPausing(t *testing.T) { - rootDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", rootDir) - - state.CloseDB() - dbPath := filepath.Join(rootDir, fmt.Sprintf("%s-surge.db", t.Name())) - state.Configure(dbPath) - defer state.CloseDB() - - progressCh := make(chan any, 16) - pool := download.NewWorkerPool(progressCh, 1) - svc := NewLocalDownloadServiceWithInput(pool, progressCh) - defer func() { _ = svc.Shutdown() }() - evCleanup := startEventWorkerForTest(t, svc) - defer evCleanup() - - id := "resume-batch-pausing-id" - ps := types.NewProgressState(id, 1024) - ps.Pause() - ps.SetPausing(true) - - destPath := filepath.Join(t.TempDir(), "file.bin") - if f, err := os.Create(destPath + ".surge"); err == nil { - _ = f.Close() - } - pool.Add(types.DownloadConfig{ - ID: id, - URL: "http://example.com/file.bin", - DestPath: destPath, - Filename: "file.bin", - State: ps, - }) - - // Ensure the pool has tracked it before we hit ResumeBatch. - st0 := waitForDownloadStatus(t, svc, id, 5*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "pausing" || st.Status == "paused" - }) - // This test targets ResumeBatch's pausing guard specifically. - // If worker scheduling already transitioned to "paused", force the - // intermediate flag back to pausing for deterministic coverage. - if st0.Status != "pausing" { - ps.SetPausing(true) - waitForDownloadStatus(t, svc, id, 2*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "pausing" - }) - } - - errs := svc.ResumeBatch([]string{id}) - if len(errs) != 1 { - t.Fatalf("unexpected errors length: got=%d want=1", len(errs)) - } - if errs[0] == nil { - t.Fatal("expected resume-batch to reject pausing download") - } - want := "download is still pausing, try again in a moment" - if got := errs[0].Error(); got != want { - t.Fatalf("unexpected error: got=%q want=%q", got, want) - } - - st, err := svc.GetStatus(id) - if err != nil { - t.Fatalf("GetStatus failed: %v", err) - } - if st == nil { - t.Fatal("missing status for pausing download") - return - } - if st.Status != "pausing" { - t.Fatalf("status changed unexpectedly after rejected resume-batch: got=%q", st.Status) - } - if !ps.IsPaused() { - t.Fatal("paused flag changed unexpectedly after rejected resume-batch") - } - if !ps.IsPausing() { - t.Fatal("pausing flag changed unexpectedly after rejected resume-batch") - } - - t.Logf("resume-batch pausing rejection preserved state: id=%s status=%s err=%s", id, st.Status, errs[0]) -} - -func TestIntegration_PauseResume_StatusFormulaInvariants(t *testing.T) { - rootDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", rootDir) - - state.CloseDB() - dbPath := filepath.Join(rootDir, fmt.Sprintf("%s-surge.db", t.Name())) - state.Configure(dbPath) - defer state.CloseDB() - - progressCh := make(chan any, 256) - pool := download.NewWorkerPool(progressCh, 1) - svc := NewLocalDownloadServiceWithInput(pool, progressCh) - forceSingleConnectionRuntime(svc) - defer func() { _ = svc.Shutdown() }() - evCleanup := startEventWorkerForTest(t, svc) - defer evCleanup() - - const fileSize = int64(64 * 1024 * 1024) - server := newDeterministicStreamingServer(t, fileSize) - defer server.Close() - - outputDir := t.TempDir() - const filename = "formula.bin" - destPath := filepath.Join(outputDir, filename) - if f, err := os.Create(destPath + ".surge"); err == nil { - _ = f.Close() - } - id, err := svc.Add(server.URL(), outputDir, filename, nil, nil, false, 0, 0, fileSize, true) - if err != nil { - t.Fatalf("add failed: %v", err) - } - - active := waitForDownloadStatus(t, svc, id, 20*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && - st.TotalSize == fileSize && - st.Downloaded > 1024*1024 && - st.Speed > 0 - }) - requireProgressClose(t, active.Progress, progressFrom(active.Downloaded, active.TotalSize), "active") - if active.ETA < 0 { - t.Fatalf("active ETA must be non-negative, got %d", active.ETA) - } - - if err := svc.Pause(id); err != nil { - t.Fatalf("pause failed: %v", err) - } - paused := waitForDownloadStatus(t, svc, id, 20*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "paused" - }) - requireProgressClose(t, paused.Progress, progressFrom(paused.Downloaded, paused.TotalSize), "paused") - if paused.Speed != 0 { - t.Fatalf("paused speed must be 0, got %.6f", paused.Speed) - } - if paused.ETA != 0 { - // API uses zero-value ETA when paused. - t.Fatalf("paused ETA must be 0, got %d", paused.ETA) - } - - // DB-only status path must preserve the same progress invariant. - entry, err := state.GetDownload(id) - if err != nil { - t.Fatalf("GetDownload failed: %v", err) - } - if entry == nil { - t.Fatal("expected paused entry in DB") - } - - statuses, err := svc.List() - if err != nil { - t.Fatalf("List failed: %v", err) - } - found := false - for _, st := range statuses { - if st.ID != id { - continue - } - found = true - requireProgressClose(t, st.Progress, progressFrom(st.Downloaded, st.TotalSize), "list") - if st.Status != "paused" && st.Status != "pausing" { - t.Fatalf("list status mismatch: got=%q", st.Status) - } - break - } - if !found { - t.Fatalf("download %s missing from List()", id) - } - - t.Logf( - "status invariants: active(downloaded=%d progress=%.4f eta=%d) paused(downloaded=%d progress=%.4f)", - active.Downloaded, - active.Progress, - active.ETA, - paused.Downloaded, - paused.Progress, - ) -} - -func TestIntegration_PauseResume_ConcreteSnapshotDebugString(t *testing.T) { - rootDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", rootDir) - - state.CloseDB() - dbPath := filepath.Join(rootDir, fmt.Sprintf("%s-surge.db", t.Name())) - state.Configure(dbPath) - defer state.CloseDB() - - progressCh := make(chan any, 256) - pool := download.NewWorkerPool(progressCh, 1) - svc := NewLocalDownloadServiceWithInput(pool, progressCh) - forceSingleConnectionRuntime(svc) - defer func() { _ = svc.Shutdown() }() - evCleanup := startEventWorkerForTest(t, svc) - defer evCleanup() - - const fileSize = int64(32 * 1024 * 1024) - server := newDeterministicStreamingServer(t, fileSize) - defer server.Close() - - outputDir := t.TempDir() - const filename = "snapshot-debug.bin" - destPath := filepath.Join(outputDir, filename) - - if f, err := os.Create(destPath + ".surge"); err == nil { - _ = f.Close() - } - id, err := svc.Add(server.URL(), outputDir, filename, nil, nil, false, 0, 0, fileSize, true) - if err != nil { - t.Fatalf("add failed: %v", err) - } - - waitForDownloadStatus(t, svc, id, 15*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && st.Downloaded > 512*1024 - }) - - if err := svc.Pause(id); err != nil { - t.Fatalf("pause failed: %v", err) - } - - paused := waitForDownloadStatus(t, svc, id, 20*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "paused" - }) - saved := waitForSavedStateByID(t, id, 20*time.Second, func(s *types.DownloadState) bool { - return s.Downloaded == paused.Downloaded && s.Elapsed > 0 - }) - - entry, err := state.GetDownload(id) - if err != nil { - t.Fatalf("GetDownload failed: %v", err) - } - if entry == nil { - t.Fatal("missing DB entry") - return - } - - snapshot := fmt.Sprintf( - "id=%s status=%s downloaded=%d total=%d progress=%.6f speed=%.6f eta=%d conns=%d saved_downloaded=%d saved_elapsed_ns=%d db_time_ms=%d tasks=%d", - id, - paused.Status, - paused.Downloaded, - paused.TotalSize, - paused.Progress, - paused.Speed, - paused.ETA, - paused.Connections, - saved.Downloaded, - saved.Elapsed, - entry.TimeTaken, - len(saved.Tasks), - ) - if snapshot == "" { - t.Fatal("unexpected empty snapshot") - } - t.Log(snapshot) -} - -func TestIntegration_PauseResumeBatch_ColdPath(t *testing.T) { - rootDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", rootDir) - - state.CloseDB() - dbPath := filepath.Join(rootDir, fmt.Sprintf("%s-surge.db", t.Name())) - state.Configure(dbPath) - defer state.CloseDB() - - const fileSize = int64(16 * 1024 * 1024) - server := newDeterministicStreamingServer(t, fileSize) - defer server.Close() - - outputDir := t.TempDir() - - // 1. Setup downloads in service instance #1 - ch1 := make(chan any, 256) - pool1 := download.NewWorkerPool(ch1, 2) - svc1 := NewLocalDownloadServiceWithInput(pool1, ch1) - forceSingleConnectionRuntime(svc1) - evCleanup1 := startEventWorkerForTest(t, svc1) - - destPath1 := filepath.Join(outputDir, "cold1.bin") - if f, err := os.Create(destPath1 + ".surge"); err == nil { - _ = f.Close() - } - id1, err := svc1.Add(server.URL(), outputDir, "cold1.bin", nil, nil, false, 0, 0, fileSize, true) - if err != nil { - t.Fatalf("add 1 failed: %v", err) - } - - destPath2 := filepath.Join(outputDir, "cold2.bin") - if f, err := os.Create(destPath2 + ".surge"); err == nil { - _ = f.Close() - } - id2, err := svc1.Add(server.URL(), outputDir, "cold2.bin", nil, nil, false, 0, 0, fileSize, true) - if err != nil { - t.Fatalf("add 2 failed: %v", err) - } - - waitForDownloadStatus(t, svc1, id1, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && st.Downloaded > 1024*512 - }) - waitForDownloadStatus(t, svc1, id2, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && st.Downloaded > 1024*512 - }) - - if err := svc1.Pause(id1); err != nil { - t.Fatalf("pause 1 failed: %v", err) - } - if err := svc1.Pause(id2); err != nil { - t.Fatalf("pause 2 failed: %v", err) - } - - saved1 := waitForSavedStateByID(t, id1, 25*time.Second, func(s *types.DownloadState) bool { return s.Elapsed > 0 }) - saved2 := waitForSavedStateByID(t, id2, 25*time.Second, func(s *types.DownloadState) bool { return s.Elapsed > 0 }) - - evCleanup1() - if err := svc1.Shutdown(); err != nil { - t.Fatalf("svc1 shutdown failed: %v", err) - } - - // 2. Setup service instance #2 (Cold start) - ch2 := make(chan any, 256) - pool2 := download.NewWorkerPool(ch2, 3) - svc2 := NewLocalDownloadServiceWithInput(pool2, ch2) - forceSingleConnectionRuntime(svc2) - defer func() { _ = svc2.Shutdown() }() - evCleanup2 := startEventWorkerForTest(t, svc2) - defer evCleanup2() - - // Hot path ID for mix test - destPathHot := filepath.Join(outputDir, "hot1.bin") - if f, err := os.Create(destPathHot + ".surge"); err == nil { - _ = f.Close() - } - idHot, err := svc2.Add(server.URL(), outputDir, "hot1.bin", nil, nil, false, 0, 0, fileSize, true) - if err != nil { - t.Fatalf("add hot failed: %v", err) - } - waitForDownloadStatus(t, svc2, idHot, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && st.Downloaded > 1024*512 - }) - if err := svc2.Pause(idHot); err != nil { - t.Fatalf("pause hot failed: %v", err) - } - waitForDownloadStatus(t, svc2, idHot, 25*time.Second, func(st *types.DownloadStatus) bool { return st.Status == "paused" }) - - // 3. Batch resume including hot, cold, and a missing ID - missingID := "non-existent-id" - batch := []string{id1, id2, idHot, missingID} - - errs := svc2.ResumeBatch(batch) - - // Validate errors - if len(errs) != 4 { - t.Fatalf("expected 4 errors, got %d", len(errs)) - } - if errs[0] != nil { - t.Errorf("unexpected error for id1: %v", errs[0]) - } - if errs[1] != nil { - t.Errorf("unexpected error for id2: %v", errs[1]) - } - if errs[2] != nil { - t.Errorf("unexpected error for idHot: %v", errs[2]) - } - if errs[3] == nil { - t.Error("expected error for missingID") - } else if errs[3].Error() != "download not found or completed" { - t.Errorf("unexpected error message for missingID: %v", errs[3]) - } - - // Wait for resumes - resumed1 := waitForDownloadStatus(t, svc2, id1, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && st.Downloaded >= saved1.Downloaded && st.Speed > 0 - }) - resumed2 := waitForDownloadStatus(t, svc2, id2, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && st.Downloaded >= saved2.Downloaded && st.Speed > 0 - }) - resumedHot := waitForDownloadStatus(t, svc2, idHot, 25*time.Second, func(st *types.DownloadStatus) bool { - return st.Status == "downloading" && st.Speed > 0 - }) - - if resumed1.Downloaded < saved1.Downloaded { - t.Errorf("cold1 downloaded = %d < saved = %d", resumed1.Downloaded, saved1.Downloaded) - } - if resumed2.Downloaded < saved2.Downloaded { - t.Errorf("cold2 downloaded = %d < saved = %d", resumed2.Downloaded, saved2.Downloaded) - } - if resumedHot.Downloaded == 0 { - t.Error("hot downloaded = 0") - } -} diff --git a/internal/core/remote_service_test.go b/internal/core/remote_service_test.go deleted file mode 100644 index 6e5fde696..000000000 --- a/internal/core/remote_service_test.go +++ /dev/null @@ -1,325 +0,0 @@ -package core - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "sync" - "testing" - "time" -) - -func TestRemoteDownloadService_StreamEvents_CleanupClosesChannel(t *testing.T) { - var once sync.Once - connected := make(chan struct{}) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/events" { - http.NotFound(w, r) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - if f, ok := w.(http.Flusher); ok { - _, _ = fmt.Fprint(w, ": ping\n\n") - f.Flush() - } - - once.Do(func() { close(connected) }) - <-r.Context().Done() - })) - defer server.Close() - - svc, err := NewRemoteDownloadService(server.URL, "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService returned error: %v", err) - } - t.Cleanup(func() { _ = svc.Shutdown() }) - - stream, cleanup, err := svc.StreamEvents(context.Background()) - if err != nil { - t.Fatalf("StreamEvents returned error: %v", err) - } - t.Cleanup(cleanup) - - select { - case <-connected: - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for SSE connection") - } - - cleanup() - - select { - case _, ok := <-stream: - if ok { - t.Fatal("expected stream channel to close after cleanup") - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for stream channel to close after cleanup") - } -} - -func TestRemoteDownloadService_StreamEvents_ShutdownClosesChannel(t *testing.T) { - var once sync.Once - connected := make(chan struct{}) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/events" { - http.NotFound(w, r) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - if f, ok := w.(http.Flusher); ok { - _, _ = fmt.Fprint(w, ": ping\n\n") - f.Flush() - } - - once.Do(func() { close(connected) }) - <-r.Context().Done() - })) - defer server.Close() - - svc, err := NewRemoteDownloadService(server.URL, "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService returned error: %v", err) - } - - stream, cleanup, err := svc.StreamEvents(context.Background()) - if err != nil { - t.Fatalf("StreamEvents returned error: %v", err) - } - t.Cleanup(cleanup) - - select { - case <-connected: - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for SSE connection") - } - - if err := svc.Shutdown(); err != nil { - t.Fatalf("Shutdown returned error: %v", err) - } - - select { - case _, ok := <-stream: - if ok { - t.Fatal("expected stream channel to close after shutdown") - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for stream channel to close after shutdown") - } -} - -// fakeRateLimitServer is a test HTTP server that validates rate-limit requests. -type fakeRateLimitServer struct { - *httptest.Server - mu sync.Mutex - perDlCalls []perDlRateCall - clearCalls []string - globalCalls []int64 - defaultCalls []int64 -} - -type perDlRateCall struct { - id string - rate int64 -} - -func newFakeRateLimitServer() *fakeRateLimitServer { - s := &fakeRateLimitServer{} - s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/rate-limit": - id := r.URL.Query().Get("id") - if r.URL.Query().Get("inherit") == "true" { - s.mu.Lock() - s.clearCalls = append(s.clearCalls, id) - s.mu.Unlock() - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"rate_limit_inherited"}`)) - return - } - rateStr := r.URL.Query().Get("rate") - rate, _ := strconv.ParseInt(rateStr, 10, 64) - s.mu.Lock() - s.perDlCalls = append(s.perDlCalls, perDlRateCall{id: id, rate: rate}) - s.mu.Unlock() - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"rate_limited"}`)) - case "/rate-limit/global": - rateStr := r.URL.Query().Get("rate") - rate, _ := strconv.ParseInt(rateStr, 10, 64) - s.mu.Lock() - s.globalCalls = append(s.globalCalls, rate) - s.mu.Unlock() - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"global_rate_limited"}`)) - case "/rate-limit/default": - rateStr := r.URL.Query().Get("rate") - rate, _ := strconv.ParseInt(rateStr, 10, 64) - s.mu.Lock() - s.defaultCalls = append(s.defaultCalls, rate) - s.mu.Unlock() - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"status":"default_rate_limited"}`)) - default: - http.NotFound(w, r) - } - })) - return s -} - -func TestRemoteDownloadService_SetRateLimit_ProxiesRequest(t *testing.T) { - srv := newFakeRateLimitServer() - defer srv.Close() - - svc, err := NewRemoteDownloadService(srv.URL, "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService: %v", err) - } - defer func() { _ = svc.Shutdown() }() - - if err := svc.SetRateLimit("dl-abc", 2_000_000); err != nil { - t.Fatalf("SetRateLimit: %v", err) - } - - srv.mu.Lock() - defer srv.mu.Unlock() - if len(srv.perDlCalls) != 1 { - t.Fatalf("expected 1 per-download call, got %d", len(srv.perDlCalls)) - } - call := srv.perDlCalls[0] - if call.id != "dl-abc" { - t.Errorf("id = %q, want dl-abc", call.id) - } - if call.rate != 2_000_000 { - t.Errorf("rate = %d, want %d", call.rate, 2_000_000) - } -} - -func TestRemoteDownloadService_ClearRateLimit_ProxiesRequest(t *testing.T) { - srv := newFakeRateLimitServer() - defer srv.Close() - - svc, err := NewRemoteDownloadService(srv.URL, "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService: %v", err) - } - defer func() { _ = svc.Shutdown() }() - - if err := svc.ClearRateLimit("dl-abc"); err != nil { - t.Fatalf("ClearRateLimit: %v", err) - } - - srv.mu.Lock() - defer srv.mu.Unlock() - if len(srv.clearCalls) != 1 { - t.Fatalf("expected 1 clear call, got %d", len(srv.clearCalls)) - } - if srv.clearCalls[0] != "dl-abc" { - t.Errorf("id = %q, want dl-abc", srv.clearCalls[0]) - } -} - -func TestRemoteDownloadService_SetGlobalRateLimit_ProxiesRequest(t *testing.T) { - srv := newFakeRateLimitServer() - defer srv.Close() - - svc, err := NewRemoteDownloadService(srv.URL, "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService: %v", err) - } - defer func() { _ = svc.Shutdown() }() - - if err := svc.SetGlobalRateLimit(5_000_000); err != nil { - t.Fatalf("SetGlobalRateLimit: %v", err) - } - - srv.mu.Lock() - defer srv.mu.Unlock() - if len(srv.globalCalls) != 1 { - t.Fatalf("expected 1 global call, got %d", len(srv.globalCalls)) - } - if srv.globalCalls[0] != 5_000_000 { - t.Errorf("rate = %d, want %d", srv.globalCalls[0], 5_000_000) - } -} - -func TestRemoteDownloadService_SetDefaultRateLimit_ProxiesRequest(t *testing.T) { - srv := newFakeRateLimitServer() - defer srv.Close() - - svc, err := NewRemoteDownloadService(srv.URL, "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService: %v", err) - } - defer func() { _ = svc.Shutdown() }() - - if err := svc.SetDefaultRateLimit(1_000_000); err != nil { - t.Fatalf("SetDefaultRateLimit: %v", err) - } - - srv.mu.Lock() - defer srv.mu.Unlock() - if len(srv.defaultCalls) != 1 { - t.Fatalf("expected 1 default call, got %d", len(srv.defaultCalls)) - } - if srv.defaultCalls[0] != 1_000_000 { - t.Errorf("rate = %d, want %d", srv.defaultCalls[0], 1_000_000) - } -} - -func TestRemoteDownloadService_SetRateLimit_RejectsNegativeRate(t *testing.T) { - svc, err := NewRemoteDownloadService("http://127.0.0.1:1", "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService: %v", err) - } - defer func() { _ = svc.Shutdown() }() - - err = svc.SetRateLimit("dl-1", -1) - if err == nil { - t.Fatal("expected error for negative rate") - } - if !strings.Contains(err.Error(), "non-negative") { - t.Fatalf("expected 'non-negative' error, got: %v", err) - } -} - -func TestRemoteDownloadService_SetGlobalRateLimit_RejectsNegativeRate(t *testing.T) { - svc, err := NewRemoteDownloadService("http://127.0.0.1:1", "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService: %v", err) - } - defer func() { _ = svc.Shutdown() }() - - err = svc.SetGlobalRateLimit(-1) - if err == nil { - t.Fatal("expected error for negative rate") - } - if !strings.Contains(err.Error(), "non-negative") { - t.Fatalf("expected 'non-negative' error, got: %v", err) - } -} - -func TestRemoteDownloadService_SetDefaultRateLimit_RejectsNegativeRate(t *testing.T) { - svc, err := NewRemoteDownloadService("http://127.0.0.1:1", "test-token", HTTPClientOptions{}) - if err != nil { - t.Fatalf("NewRemoteDownloadService: %v", err) - } - defer func() { _ = svc.Shutdown() }() - - err = svc.SetDefaultRateLimit(-1) - if err == nil { - t.Fatal("expected error for negative rate") - } - if !strings.Contains(err.Error(), "non-negative") { - t.Fatalf("expected 'non-negative' error, got: %v", err) - } -} diff --git a/internal/core/test_helpers_test.go b/internal/core/test_helpers_test.go deleted file mode 100644 index bde34adfa..000000000 --- a/internal/core/test_helpers_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package core - -import ( - "context" - "sync" - "testing" - - "github.com/SurgeDM/Surge/internal/processing" -) - -// startEventWorkerForTest wires up a LifecycleManager event worker to the -// service's event stream. This is required because DB persistence was moved -// from the Engine into the Processing layer. Tests that expect database state -// to appear after pause/complete must call this. -// -// Returns a wait function. Call it after svc.Shutdown() to block until the -// event worker has drained all buffered events and finished DB writes. -func startEventWorkerForTest(t *testing.T, svc *LocalDownloadService) func() { - t.Helper() - - mgr := processing.NewLifecycleManager(nil, nil) - stream, cleanup, err := svc.StreamEvents(context.Background()) - if err != nil { - t.Fatalf("startEventWorkerForTest: failed to stream events: %v", err) - } - - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - mgr.StartEventWorker(stream) - }() - - svc.SetLifecycleHooks(LifecycleHooks{ - Pause: mgr.Pause, - Resume: mgr.Resume, - ResumeBatch: mgr.ResumeBatch, - Cancel: mgr.Cancel, - UpdateURL: mgr.UpdateURL, - }) - mgr.SetEngineHooks(processing.EngineHooks{ - Pause: svc.Pool.Pause, - ExtractPausedConfig: svc.Pool.ExtractPausedConfig, - GetStatus: svc.Pool.GetStatus, - AddConfig: svc.Pool.Add, - Cancel: svc.Pool.Cancel, - UpdateURL: svc.Pool.UpdateURL, - PublishEvent: svc.Publish, - }) - - return func() { - cleanup() // closes the channel, causing StartEventWorker to exit - wg.Wait() // wait for all DB writes to complete - } -} diff --git a/internal/engine/events/codec_test.go b/internal/engine/events/codec_test.go deleted file mode 100644 index 233ef60c1..000000000 --- a/internal/engine/events/codec_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package events - -import ( - "encoding/json" - "errors" - "testing" -) - -func TestEventTypeForMessage(t *testing.T) { - tests := []struct { - name string - msg interface{} - wantType string - wantFound bool - }{ - {name: "progress", msg: ProgressMsg{}, wantType: EventTypeProgress, wantFound: true}, - {name: "started", msg: DownloadStartedMsg{}, wantType: EventTypeStarted, wantFound: true}, - {name: "complete", msg: DownloadCompleteMsg{}, wantType: EventTypeComplete, wantFound: true}, - {name: "error", msg: DownloadErrorMsg{}, wantType: EventTypeError, wantFound: true}, - {name: "paused", msg: DownloadPausedMsg{}, wantType: EventTypePaused, wantFound: true}, - {name: "resumed", msg: DownloadResumedMsg{}, wantType: EventTypeResumed, wantFound: true}, - {name: "queued", msg: DownloadQueuedMsg{}, wantType: EventTypeQueued, wantFound: true}, - {name: "removed", msg: DownloadRemovedMsg{}, wantType: EventTypeRemoved, wantFound: true}, - {name: "request", msg: DownloadRequestMsg{}, wantType: EventTypeRequest, wantFound: true}, - {name: "system", msg: SystemLogMsg{}, wantType: EventTypeSystem, wantFound: true}, - {name: "unknown", msg: struct{}{}, wantType: "", wantFound: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotType, gotFound := EventTypeForMessage(tt.msg) - if gotType != tt.wantType || gotFound != tt.wantFound { - t.Fatalf("EventTypeForMessage(%T) = (%q, %v), want (%q, %v)", tt.msg, gotType, gotFound, tt.wantType, tt.wantFound) - } - }) - } -} - -func TestEncodeSSEMessages_BatchProgress(t *testing.T) { - batch := BatchProgressMsg{ - {DownloadID: "a", Downloaded: 1, Total: 10}, - {DownloadID: "b", Downloaded: 2, Total: 10}, - } - - frames, err := EncodeSSEMessages(batch) - if err != nil { - t.Fatalf("EncodeSSEMessages(batch) failed: %v", err) - } - if len(frames) != 2 { - t.Fatalf("EncodeSSEMessages(batch) produced %d frames, want 2", len(frames)) - } - - for i, frame := range frames { - if frame.Event != EventTypeProgress { - t.Fatalf("frame[%d] event = %q, want %q", i, frame.Event, EventTypeProgress) - } - var decoded ProgressMsg - if err := json.Unmarshal(frame.Data, &decoded); err != nil { - t.Fatalf("frame[%d] data failed to decode: %v", i, err) - } - if decoded.DownloadID != batch[i].DownloadID { - t.Fatalf("frame[%d] decoded ID = %q, want %q", i, decoded.DownloadID, batch[i].DownloadID) - } - } -} - -func TestEncodeDecodeSSEMessage_RoundTrip(t *testing.T) { - original := DownloadErrorMsg{ - DownloadID: "dl-1", - Filename: "file.bin", - DestPath: "/tmp/file.bin", - Err: errors.New("boom"), - } - - frames, err := EncodeSSEMessages(original) - if err != nil { - t.Fatalf("EncodeSSEMessages failed: %v", err) - } - if len(frames) != 1 { - t.Fatalf("EncodeSSEMessages produced %d frames, want 1", len(frames)) - } - - decoded, ok, err := DecodeSSEMessage(frames[0].Event, frames[0].Data) - if err != nil { - t.Fatalf("DecodeSSEMessage failed: %v", err) - } - if !ok { - t.Fatal("DecodeSSEMessage reported unknown event for known frame") - } - - msg, castOK := decoded.(DownloadErrorMsg) - if !castOK { - t.Fatalf("decoded message type = %T, want DownloadErrorMsg", decoded) - } - if msg.DownloadID != original.DownloadID || msg.Filename != original.Filename || msg.DestPath != original.DestPath { - t.Fatalf("decoded message mismatch: got %+v want %+v", msg, original) - } - if msg.Err == nil || msg.Err.Error() != "boom" { - t.Fatalf("decoded error mismatch: got %v", msg.Err) - } -} - -func TestDecodeSSEMessage_UnknownType(t *testing.T) { - decoded, ok, err := DecodeSSEMessage("not-a-real-event", []byte(`{"x":1}`)) - if err != nil { - t.Fatalf("DecodeSSEMessage returned unexpected error: %v", err) - } - if ok { - t.Fatalf("DecodeSSEMessage reported known type for unknown event: %+v", decoded) - } - if decoded != nil { - t.Fatalf("DecodeSSEMessage decoded unknown event payload: %+v", decoded) - } -} diff --git a/internal/engine/events/events.go b/internal/engine/events/events.go deleted file mode 100644 index ff1ec257f..000000000 --- a/internal/engine/events/events.go +++ /dev/null @@ -1,338 +0,0 @@ -package events - -import ( - "encoding/json" - "errors" - "time" - - "github.com/SurgeDM/Surge/internal/engine/types" -) - -// ProgressMsg represents a progress update from the downloader -type ProgressMsg struct { - DownloadID string - Downloaded int64 - Total int64 - Speed float64 // bytes per second - Elapsed time.Duration - ActiveConnections int - ChunkBitmap []byte - BitmapWidth int - ActualChunkSize int64 - ChunkProgress []int64 - RateLimited bool -} - -// DownloadCompleteMsg signals that the download finished successfully -type DownloadCompleteMsg struct { - DownloadID string - Filename string - Elapsed time.Duration - Total int64 - AvgSpeed float64 // Average download speed in bytes/sec - RateLimit int64 - RateLimitSet bool -} - -// DownloadErrorMsg signals that an error occurred -type DownloadErrorMsg struct { - DownloadID string - Filename string - DestPath string - Err error -} - -func (m DownloadErrorMsg) MarshalJSON() ([]byte, error) { - type encoded struct { - DownloadID string `json:"DownloadID"` - Filename string `json:"Filename,omitempty"` - DestPath string `json:"DestPath,omitempty"` - Err string `json:"Err,omitempty"` - } - - out := encoded{ - DownloadID: m.DownloadID, - Filename: m.Filename, - DestPath: m.DestPath, - } - if m.Err != nil { - out.Err = m.Err.Error() - } - - return json.Marshal(out) -} - -func (m *DownloadErrorMsg) UnmarshalJSON(data []byte) error { - var aux struct { - DownloadID string `json:"DownloadID"` - Filename string `json:"Filename"` - DestPath string `json:"DestPath"` - Err json.RawMessage `json:"Err"` - } - if err := json.Unmarshal(data, &aux); err != nil { - return err - } - - m.DownloadID = aux.DownloadID - m.Filename = aux.Filename - m.DestPath = aux.DestPath - m.Err = nil - - if len(aux.Err) == 0 { - return nil - } - - // Most common case: server sends Err as a string. - var errStr string - if err := json.Unmarshal(aux.Err, &errStr); err == nil { - if errStr != "" { - m.Err = errors.New(errStr) - } - return nil - } - - // Backward/forward compatibility: accept non-string payloads (e.g. {}). - raw := string(aux.Err) - if raw != "" && raw != "null" { - m.Err = errors.New(raw) - } - return nil -} - -// DownloadStartedMsg is sent when a download actually starts (after metadata fetch) -type DownloadStartedMsg struct { - DownloadID string - URL string - Filename string - Total int64 - DestPath string // Full path to the destination file - State *types.ProgressState `json:"-"` - RateLimit int64 - RateLimitSet bool - Workers int - MinChunkSize int64 -} - -type DownloadPausedMsg struct { - DownloadID string - Filename string - Downloaded int64 - State *types.DownloadState `json:"-"` - RateLimit int64 - RateLimitSet bool - Workers int - MinChunkSize int64 -} - -type DownloadResumedMsg struct { - DownloadID string - Filename string -} - -type DownloadQueuedMsg struct { - DownloadID string - Filename string - URL string - DestPath string - Mirrors []string - RateLimit int64 - RateLimitSet bool - Workers int - MinChunkSize int64 -} - -type DownloadRemovedMsg struct { - DownloadID string - Filename string - DestPath string - Completed bool -} - -// SystemLogMsg carries informational system-level log messages for clients/UI. -type SystemLogMsg struct { - Message string -} - -// BatchProgressMsg represents a batch of progress updates to reduce TUI render calls -type BatchProgressMsg []ProgressMsg - -// DownloadRequestMsg signals a request to start a download (e.g. from extension) -// that may need user confirmation or duplicate checking -type DownloadRequestMsg struct { - ID string - URL string - Filename string - Path string - Mirrors []string - Headers map[string]string - Workers int - MinChunkSize int64 -} - -// BatchDownloadRequestMsg signals a batch request that should be confirmed once. -type BatchDownloadRequestMsg struct { - ID string - Path string - Requests []DownloadRequestMsg -} - -const ( - EventTypeProgress = "progress" - EventTypeStarted = "started" - EventTypeComplete = "complete" - EventTypeError = "error" - EventTypePaused = "paused" - EventTypeResumed = "resumed" - EventTypeQueued = "queued" - EventTypeRemoved = "removed" - EventTypeRequest = "request" - EventTypeBatchRequest = "batch_request" - EventTypeSystem = "system" -) - -// SSEMessage represents one server-sent event frame. -type SSEMessage struct { - Event string - Data []byte -} - -// EncodeSSEMessages converts an event payload into one or more SSE messages. -// BatchProgressMsg is flattened into multiple "progress" events. -func EncodeSSEMessages(msg interface{}) ([]SSEMessage, error) { - switch m := msg.(type) { - case BatchProgressMsg: - frames := make([]SSEMessage, 0, len(m)) - for _, p := range m { - data, err := json.Marshal(p) - if err != nil { - return nil, err - } - frames = append(frames, SSEMessage{ - Event: EventTypeProgress, - Data: data, - }) - } - return frames, nil - default: - eventType, ok := EventTypeForMessage(msg) - if !ok { - return nil, nil - } - data, err := json.Marshal(msg) - if err != nil { - return nil, err - } - return []SSEMessage{{ - Event: eventType, - Data: data, - }}, nil - } -} - -// EventTypeForMessage maps message payloads to SSE event type names. -func EventTypeForMessage(msg interface{}) (string, bool) { - switch msg.(type) { - case ProgressMsg: - return EventTypeProgress, true - case DownloadStartedMsg: - return EventTypeStarted, true - case DownloadCompleteMsg: - return EventTypeComplete, true - case DownloadErrorMsg: - return EventTypeError, true - case DownloadPausedMsg: - return EventTypePaused, true - case DownloadResumedMsg: - return EventTypeResumed, true - case DownloadQueuedMsg: - return EventTypeQueued, true - case DownloadRemovedMsg: - return EventTypeRemoved, true - case DownloadRequestMsg: - return EventTypeRequest, true - case BatchDownloadRequestMsg: - return EventTypeBatchRequest, true - case SystemLogMsg: - return EventTypeSystem, true - default: - return "", false - } -} - -// DecodeSSEMessage decodes one SSE event payload into the corresponding message. -func DecodeSSEMessage(eventType string, data []byte) (interface{}, bool, error) { - var msg interface{} - - switch eventType { - case EventTypeProgress: - var m ProgressMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeStarted: - var m DownloadStartedMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeComplete: - var m DownloadCompleteMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeError: - var m DownloadErrorMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypePaused: - var m DownloadPausedMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeResumed: - var m DownloadResumedMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeQueued: - var m DownloadQueuedMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeRemoved: - var m DownloadRemovedMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeRequest: - var m DownloadRequestMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeBatchRequest: - var m BatchDownloadRequestMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - case EventTypeSystem: - var m SystemLogMsg - if err := json.Unmarshal(data, &m); err != nil { - return nil, true, err - } - msg = m - default: - return nil, false, nil - } - - return msg, true, nil -} diff --git a/internal/engine/events/events_test.go b/internal/engine/events/events_test.go deleted file mode 100644 index bab45a972..000000000 --- a/internal/engine/events/events_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package events - -import ( - "errors" - "fmt" - "reflect" - "testing" - "time" -) - -func TestDownloadPausedMsg_Creation(t *testing.T) { - msg := DownloadPausedMsg{ - DownloadID: "immediate-pause", - Downloaded: 0, - } - - if msg.Downloaded != 0 { - t.Error("Immediate pause should have 0 bytes downloaded") - } -} - -// ============================================================================= -// DownloadResumedMsg Tests -// ============================================================================= - -func TestDownloadResumedMsg_Creation(t *testing.T) { - msg := DownloadResumedMsg{ - DownloadID: "resumed-123", - } - - if msg.DownloadID != "resumed-123" { - t.Errorf("Expected DownloadID 'resumed-123', got %s", msg.DownloadID) - } -} - -func TestDownloadResumedMsg_ZeroValues(t *testing.T) { - var msg DownloadResumedMsg - - if msg.DownloadID != "" { - t.Error("Zero value DownloadID should be empty") - } -} - -// ============================================================================= -// Message Type Assertions (for interface compatibility) -// ============================================================================= - -func TestMessageTypes_AreDistinct(t *testing.T) { - // Verify all message types are distinct and can be type-switched - messages := []interface{}{ - ProgressMsg{DownloadID: "progress"}, - DownloadCompleteMsg{DownloadID: "complete"}, - DownloadErrorMsg{DownloadID: "error"}, - DownloadStartedMsg{DownloadID: "started"}, - DownloadPausedMsg{DownloadID: "paused"}, - DownloadResumedMsg{DownloadID: "resumed"}, - } - - typeNames := make(map[string]bool) - for _, msg := range messages { - typeName := fmt.Sprintf("%T", msg) - if typeNames[typeName] { - t.Errorf("Duplicate type: %s", typeName) - } - typeNames[typeName] = true - } - - if len(typeNames) != 6 { - t.Errorf("Expected 6 distinct types, got %d", len(typeNames)) - } -} - -func TestMessageTypes_TypeSwitch(t *testing.T) { - var msg interface{} = ProgressMsg{DownloadID: "test"} - - switch m := msg.(type) { - case ProgressMsg: - if m.DownloadID != "test" { - t.Error("Type switch should preserve value") - } - default: - t.Error("Should match ProgressMsg") - } -} - -// ============================================================================= -// Channel Communication Tests -// ============================================================================= - -func TestProgressMsg_ChannelCommunication(t *testing.T) { - ch := make(chan ProgressMsg, 1) - - sent := ProgressMsg{ - DownloadID: "channel-test", - Downloaded: 1000, - Total: 2000, - } - - ch <- sent - received := <-ch - - if !reflect.DeepEqual(received, sent) { - t.Error("Message should be identical after channel send/receive") - } -} - -func TestDownloadCompleteMsg_ChannelCommunication(t *testing.T) { - ch := make(chan DownloadCompleteMsg, 1) - - sent := DownloadCompleteMsg{ - DownloadID: "channel-complete", - Elapsed: 5 * time.Second, - } - - ch <- sent - received := <-ch - - if received.DownloadID != sent.DownloadID { - t.Error("DownloadID should match") - } - if received.Elapsed != sent.Elapsed { - t.Error("Elapsed should match") - } -} - -func TestDownloadErrorMsg_ChannelCommunication(t *testing.T) { - ch := make(chan DownloadErrorMsg, 1) - - err := errors.New("test error") - sent := DownloadErrorMsg{ - DownloadID: "channel-error", - Err: err, - } - - ch <- sent - received := <-ch - - if received.Err.Error() != err.Error() { - t.Error("Error should match") - } -} - -// ============================================================================= -// Edge Cases and Special Characters -// ============================================================================= - -func TestDownloadStartedMsg_SpecialFilenames(t *testing.T) { - testCases := []struct { - name string - filename string - }{ - {"with spaces", "my file.zip"}, - {"unicode", "文件.zip"}, - {"special chars", "file (1).zip"}, - {"very long", string(make([]byte, 255))}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - msg := DownloadStartedMsg{ - Filename: tc.filename, - } - if msg.Filename != tc.filename { - t.Errorf("Filename not preserved: %s", tc.filename) - } - }) - } -} - -func TestDownloadStartedMsg_URLVariants(t *testing.T) { - testCases := []struct { - name string - url string - }{ - {"http", "http://example.com/file"}, - {"https", "https://example.com/file"}, - {"with port", "https://example.com:8080/file"}, - {"with query", "https://example.com/file?key=value"}, - {"with fragment", "https://example.com/file#section"}, - {"ftp", "ftp://example.com/file"}, - {"ipv4", "http://192.168.1.1/file"}, - {"ipv6", "http://[::1]/file"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - msg := DownloadStartedMsg{ - URL: tc.url, - } - if msg.URL != tc.url { - t.Errorf("URL not preserved: %s", tc.url) - } - }) - } -} - -// ============================================================================= -// Equality and Comparison Tests -// ============================================================================= - -func TestProgressMsg_Equality(t *testing.T) { - msg1 := ProgressMsg{ - DownloadID: "equal", - Downloaded: 100, - Total: 200, - Speed: 50, - ActiveConnections: 2, - } - msg2 := ProgressMsg{ - DownloadID: "equal", - Downloaded: 100, - Total: 200, - Speed: 50, - ActiveConnections: 2, - } - - if !reflect.DeepEqual(msg1, msg2) { - t.Error("Identical ProgressMsg should be equal") - } -} - -func TestDownloadCompleteMsg_Equality(t *testing.T) { - elapsed := 5 * time.Second - msg1 := DownloadCompleteMsg{ - DownloadID: "equal", - Filename: "file.zip", - Elapsed: elapsed, - Total: 1000, - } - msg2 := DownloadCompleteMsg{ - DownloadID: "equal", - Filename: "file.zip", - Elapsed: elapsed, - Total: 1000, - } - - if msg1 != msg2 { - t.Error("Identical DownloadCompleteMsg should be equal") - } -} - -// Note: DownloadErrorMsg equality is tricky because error comparison -// compares pointer/interface, not value - -func TestDownloadPausedMsg_Equality(t *testing.T) { - msg1 := DownloadPausedMsg{DownloadID: "equal", Downloaded: 500} - msg2 := DownloadPausedMsg{DownloadID: "equal", Downloaded: 500} - - if msg1 != msg2 { - t.Error("Identical DownloadPausedMsg should be equal") - } -} - -func TestDownloadResumedMsg_Equality(t *testing.T) { - msg1 := DownloadResumedMsg{DownloadID: "equal"} - msg2 := DownloadResumedMsg{DownloadID: "equal"} - - if msg1 != msg2 { - t.Error("Identical DownloadResumedMsg should be equal") - } -} diff --git a/internal/engine/types/progress.go b/internal/engine/types/progress.go deleted file mode 100644 index 6e4ae01a3..000000000 --- a/internal/engine/types/progress.go +++ /dev/null @@ -1,628 +0,0 @@ -package types - -import ( - "context" - "sync" - "sync/atomic" - "time" - - "github.com/SurgeDM/Surge/internal/utils" -) - -type ProgressState struct { - ID string - Downloaded atomic.Int64 - TotalSize int64 - DestPath string // Initial destination path - Filename string // Initial filename - URL string // Source URL - StartTime time.Time - ActiveWorkers atomic.Int32 - Done atomic.Bool - Error atomic.Pointer[error] - Paused atomic.Bool - Pausing atomic.Bool // Intermediate state: Pause requested but workers not yet exited - RateLimited atomic.Bool // Set when the downloader is backing off due to HTTP 429/rate-limit - cancelFunc context.CancelFunc - - VerifiedProgress atomic.Int64 // Verified bytes written to disk (for UI progress) - SessionStartBytes int64 // SessionStartBytes tracks how many bytes were already downloaded when the current session started - SavedElapsed time.Duration // Time spent in previous sessions - RateLimitBps int64 // Effective per-download rate limit in bytes/sec - RateLimitSet bool // Whether RateLimitBps is an explicit per-download override - - Mirrors []MirrorStatus - - ChunkBitmap []byte - ChunkProgress []int64 - ActualChunkSize int64 - BitmapWidth int - - mu sync.Mutex // Protects TotalSize, StartTime, SessionStartBytes, SavedElapsed, Mirrors -} - -type MirrorStatus struct { - URL string - Active bool - Error bool -} - -func (ps *ProgressState) SetDestPath(path string) { - ps.mu.Lock() - defer ps.mu.Unlock() - ps.DestPath = path -} - -func (ps *ProgressState) GetDestPath() string { - ps.mu.Lock() - defer ps.mu.Unlock() - return ps.DestPath -} - -func (ps *ProgressState) SetFilename(filename string) { - ps.mu.Lock() - defer ps.mu.Unlock() - ps.Filename = filename -} - -func (ps *ProgressState) GetFilename() string { - ps.mu.Lock() - defer ps.mu.Unlock() - return ps.Filename -} - -func (ps *ProgressState) SetURL(url string) { - ps.mu.Lock() - defer ps.mu.Unlock() - ps.URL = url -} - -func (ps *ProgressState) GetURL() string { - ps.mu.Lock() - defer ps.mu.Unlock() - return ps.URL -} - -func (ps *ProgressState) SetRateLimit(rate int64, explicit bool) { - ps.mu.Lock() - defer ps.mu.Unlock() - ps.RateLimitBps = rate - ps.RateLimitSet = explicit -} - -func (ps *ProgressState) GetRateLimit() (int64, bool) { - ps.mu.Lock() - defer ps.mu.Unlock() - return ps.RateLimitBps, ps.RateLimitSet -} - -func NewProgressState(id string, totalSize int64) *ProgressState { - return &ProgressState{ - ID: id, - TotalSize: totalSize, - StartTime: time.Now(), - } -} - -func (ps *ProgressState) SetTotalSize(size int64) { - ps.mu.Lock() - defer ps.mu.Unlock() - - // If size is already set and timer is running, don't reset the clock. - // This prevents post-download updates from erasing the session duration. - if ps.TotalSize == size && !ps.StartTime.IsZero() { - return - } - - ps.TotalSize = size - ps.SessionStartBytes = ps.VerifiedProgress.Load() - ps.StartTime = time.Now() -} - -func (ps *ProgressState) SyncSessionStart() { - ps.mu.Lock() - defer ps.mu.Unlock() - ps.SessionStartBytes = ps.VerifiedProgress.Load() - ps.StartTime = time.Now() -} - -func (ps *ProgressState) SetError(err error) { - ps.Error.Store(&err) -} - -func (ps *ProgressState) GetError() error { - if e := ps.Error.Load(); e != nil { - return *e - } - return nil -} - -func (ps *ProgressState) GetProgress() (downloaded int64, total int64, totalElapsed time.Duration, sessionElapsed time.Duration, connections int32, sessionStartBytes int64) { - downloaded = ps.VerifiedProgress.Load() - connections = ps.ActiveWorkers.Load() - paused := ps.Paused.Load() - - ps.mu.Lock() - total = ps.TotalSize - savedElapsed := ps.SavedElapsed - startTime := ps.StartTime - sessionStartBytes = ps.SessionStartBytes - ps.mu.Unlock() - - // Elapsed time excludes paused duration. - if paused { - sessionElapsed = 0 - totalElapsed = savedElapsed - } else { - sessionElapsed = time.Since(startTime) - if sessionElapsed < 0 { - sessionElapsed = 0 - } - totalElapsed = savedElapsed + sessionElapsed - } - if totalElapsed < 0 { - totalElapsed = 0 - } - - return -} - -func (ps *ProgressState) Pause() { - ps.Paused.Store(true) - ps.mu.Lock() - defer ps.mu.Unlock() - if ps.cancelFunc != nil { - ps.cancelFunc() - } -} - -func (ps *ProgressState) SetCancelFunc(cancel context.CancelFunc) { - ps.mu.Lock() - defer ps.mu.Unlock() - ps.cancelFunc = cancel -} - -func (ps *ProgressState) Resume() { - ps.Paused.Store(false) -} - -func (ps *ProgressState) IsPaused() bool { - return ps.Paused.Load() -} - -func (ps *ProgressState) SetPausing(pausing bool) { - ps.Pausing.Store(pausing) -} - -func (ps *ProgressState) IsPausing() bool { - return ps.Pausing.Load() -} - -func (ps *ProgressState) SetSavedElapsed(d time.Duration) { - ps.mu.Lock() - defer ps.mu.Unlock() - ps.SavedElapsed = d -} - -func (ps *ProgressState) GetSavedElapsed() time.Duration { - ps.mu.Lock() - defer ps.mu.Unlock() - return ps.SavedElapsed -} - -// FinalizeSession closes the current session and accumulates its elapsed time into total elapsed. -// It returns (sessionElapsed, totalElapsedAfterFinalize). -func (ps *ProgressState) FinalizeSession(downloaded int64) (time.Duration, time.Duration) { - if downloaded < 0 { - downloaded = ps.VerifiedProgress.Load() - } - - now := time.Now() - ps.mu.Lock() - sessionElapsed := now.Sub(ps.StartTime) - if sessionElapsed < 0 { - sessionElapsed = 0 - } - ps.SavedElapsed += sessionElapsed - if ps.SavedElapsed < 0 { - ps.SavedElapsed = 0 - } - ps.SessionStartBytes = downloaded - ps.StartTime = now - totalElapsed := ps.SavedElapsed - ps.mu.Unlock() - - ps.Downloaded.Store(downloaded) - ps.VerifiedProgress.Store(downloaded) - - return sessionElapsed, totalElapsed -} - -// SessionReset wipes the current progress and session state, allowing for a fresh start (e.g. fallback). -func (ps *ProgressState) SessionReset() { - ps.mu.Lock() - defer ps.mu.Unlock() - - ps.Downloaded.Store(0) - ps.VerifiedProgress.Store(0) - ps.SessionStartBytes = 0 - ps.StartTime = time.Now() - ps.SavedElapsed = 0 - ps.ActiveWorkers.Store(0) - ps.Done.Store(false) - ps.Paused.Store(false) - ps.Pausing.Store(false) - ps.RateLimited.Store(false) - ps.Error.Store(nil) - - // Clear mirrors error status - for i := range ps.Mirrors { - ps.Mirrors[i].Error = false - } - - // Reset chunk tracking if initialized - if ps.BitmapWidth > 0 { - ps.ChunkBitmap = make([]byte, len(ps.ChunkBitmap)) - ps.ChunkProgress = make([]int64, ps.BitmapWidth) - } -} - -// FinalizePauseSession finalizes the current session for a pause transition. -// It keeps timing/data frozen while paused and returns total elapsed after finalize. -func (ps *ProgressState) FinalizePauseSession(downloaded int64) time.Duration { - _, total := ps.FinalizeSession(downloaded) - return total -} - -func (ps *ProgressState) SetMirrors(mirrors []MirrorStatus) { - ps.mu.Lock() - defer ps.mu.Unlock() - // Deep copy to prevent race conditions if caller modifies the slice - ps.Mirrors = make([]MirrorStatus, len(mirrors)) - copy(ps.Mirrors, mirrors) -} - -func (ps *ProgressState) GetMirrors() []MirrorStatus { - ps.mu.Lock() - defer ps.mu.Unlock() - // Return a copy - if len(ps.Mirrors) == 0 { - return nil - } - mirrors := make([]MirrorStatus, len(ps.Mirrors)) - copy(mirrors, ps.Mirrors) - return mirrors -} - -// ChunkStatus represents the status of a visualization chunk -type ChunkStatus int - -const ( - ChunkPending ChunkStatus = 0 // 00 - ChunkDownloading ChunkStatus = 1 // 01 - ChunkCompleted ChunkStatus = 2 // 10 (Bit 2 set) -) - -// bitmapLayout returns the number of tracked chunks and backing bytes for a -// 2-bit-per-chunk bitmap. -func bitmapLayout(totalSize, chunkSize int64) (numChunks int, bytesNeeded int, ok bool) { - if totalSize <= 0 || chunkSize <= 0 { - return 0, 0, false - } - - numChunks = int((totalSize + chunkSize - 1) / chunkSize) - if numChunks <= 0 { - return 0, 0, false - } - - bytesNeeded = (numChunks + 3) / 4 - return numChunks, bytesNeeded, true -} - -// InitBitmap initializes the chunk bitmap -func (ps *ProgressState) InitBitmap(totalSize int64, chunkSize int64) { - ps.mu.Lock() - defer ps.mu.Unlock() - - if len(ps.ChunkBitmap) > 0 && ps.TotalSize == totalSize && ps.ActualChunkSize == chunkSize { - return - } - - utils.Debug("InitBitmap: Total=%d, ChunkSize=%d", totalSize, chunkSize) - - if chunkSize <= 0 { - return - } - - numChunks, bytesNeeded, ok := bitmapLayout(totalSize, chunkSize) - if !ok { - return - } - - ps.ActualChunkSize = chunkSize - ps.BitmapWidth = numChunks - ps.ChunkBitmap = make([]byte, bytesNeeded) - ps.ChunkProgress = make([]int64, numChunks) -} - -// RestoreBitmap restores the chunk bitmap from saved state -func (ps *ProgressState) RestoreBitmap(bitmap []byte, actualChunkSize int64) { - ps.mu.Lock() - defer ps.mu.Unlock() - - if len(bitmap) == 0 || actualChunkSize <= 0 || ps.TotalSize <= 0 { - return - } - - numChunks, bytesNeeded, ok := bitmapLayout(ps.TotalSize, actualChunkSize) - if !ok { - return - } - - // Deep copy to prevent mutation hazard of caller's backing array - ps.ChunkBitmap = make([]byte, bytesNeeded) - copy(ps.ChunkBitmap, bitmap) - ps.ActualChunkSize = actualChunkSize - ps.BitmapWidth = numChunks - - if len(ps.ChunkProgress) != numChunks { - ps.ChunkProgress = make([]int64, numChunks) - } -} - -// SetChunkProgress updates chunk progress array from external sources (e.g. remote events). -func (ps *ProgressState) SetChunkProgress(progress []int64) { - ps.mu.Lock() - defer ps.mu.Unlock() - - if len(progress) == 0 { - return - } - if len(ps.ChunkProgress) != len(progress) { - ps.ChunkProgress = make([]int64, len(progress)) - } - copy(ps.ChunkProgress, progress) -} - -// SetChunkState sets the 2-bit state for a specific chunk index (thread-safe) -func (ps *ProgressState) SetChunkState(index int, status ChunkStatus) { - ps.mu.Lock() - defer ps.mu.Unlock() - ps.setChunkState(index, status) -} - -// setChunkState sets the 2-bit state (internal, expects lock) -func (ps *ProgressState) setChunkState(index int, status ChunkStatus) { - if index < 0 || index >= ps.BitmapWidth { - return - } - - byteIndex := index / 4 - if byteIndex >= len(ps.ChunkBitmap) { - return - } - bitOffset := (index % 4) * 2 - - mask := byte(3 << bitOffset) - ps.ChunkBitmap[byteIndex] &= ^mask - - val := byte(status) << bitOffset - ps.ChunkBitmap[byteIndex] |= val -} - -// GetChunkState gets the 2-bit state for a specific chunk index (thread-safe) -func (ps *ProgressState) GetChunkState(index int) ChunkStatus { - ps.mu.Lock() - defer ps.mu.Unlock() - return ps.getChunkState(index) -} - -// getChunkState gets the 2-bit state (internal, expects lock) -func (ps *ProgressState) getChunkState(index int) ChunkStatus { - if index < 0 || index >= ps.BitmapWidth { - return ChunkPending - } - - byteIndex := index / 4 - if byteIndex >= len(ps.ChunkBitmap) { - return ChunkPending - } - bitOffset := (index % 4) * 2 - - val := (ps.ChunkBitmap[byteIndex] >> bitOffset) & 3 - return ChunkStatus(val) -} - -// UpdateChunkStatus updates the bitmap based on byte range -func (ps *ProgressState) UpdateChunkStatus(offset, length int64, status ChunkStatus) { - ps.mu.Lock() - - if ps.ActualChunkSize == 0 || len(ps.ChunkBitmap) == 0 { - ps.mu.Unlock() - return - } - - if len(ps.ChunkProgress) != ps.BitmapWidth { - utils.Debug("UpdateChunkStatus: Initializing ChunkProgress array (width=%d)", ps.BitmapWidth) - ps.ChunkProgress = make([]int64, ps.BitmapWidth) - } - - startIdx := int(offset / ps.ActualChunkSize) - endIdx := int((offset + length - 1) / ps.ActualChunkSize) - - if startIdx < 0 { - startIdx = 0 - } - if endIdx >= ps.BitmapWidth { - endIdx = ps.BitmapWidth - 1 - } - - var totalIncrement int64 - - for i := startIdx; i <= endIdx; i++ { - // Calculate precise overlap with this chunk - chunkStart := int64(i) * ps.ActualChunkSize - chunkEnd := chunkStart + ps.ActualChunkSize - if chunkEnd > ps.TotalSize { - chunkEnd = ps.TotalSize - } - - updateStart := offset - if updateStart < chunkStart { - updateStart = chunkStart - } - - updateEnd := offset + length - if updateEnd > chunkEnd { - updateEnd = chunkEnd - } - - overlap := updateEnd - updateStart - if overlap < 0 { - overlap = 0 - } - - switch status { - case ChunkCompleted: - increment := overlap - remainingSpace := (chunkEnd - chunkStart) - ps.ChunkProgress[i] - - if increment > remainingSpace { - increment = remainingSpace - } - - if increment > 0 { - ps.ChunkProgress[i] += increment - totalIncrement += increment - } - - if ps.ChunkProgress[i] >= (chunkEnd - chunkStart) { - ps.ChunkProgress[i] = chunkEnd - chunkStart - ps.setChunkState(i, ChunkCompleted) - } else { - if ps.getChunkState(i) != ChunkCompleted { - ps.setChunkState(i, ChunkDownloading) - } - } - case ChunkDownloading: - current := ps.getChunkState(i) - if current != ChunkCompleted { - ps.setChunkState(i, ChunkDownloading) - } - } - } - - ps.mu.Unlock() - - if totalIncrement > 0 { - ps.VerifiedProgress.Add(totalIncrement) - } -} - -// RecalculateProgress reconstructs ChunkProgress from remaining tasks (for resume) -func (ps *ProgressState) RecalculateProgress(remainingTasks []Task) { - ps.mu.Lock() - defer ps.mu.Unlock() - - if ps.ActualChunkSize == 0 || ps.BitmapWidth == 0 { - return - } - - ps.ChunkProgress = make([]int64, ps.BitmapWidth) - var totalVerified int64 - for i := 0; i < ps.BitmapWidth; i++ { - chunkStart := int64(i) * ps.ActualChunkSize - chunkEnd := chunkStart + ps.ActualChunkSize - if chunkEnd > ps.TotalSize { - chunkEnd = ps.TotalSize - } - ps.ChunkProgress[i] = chunkEnd - chunkStart - totalVerified += ps.ChunkProgress[i] - } - - for _, task := range remainingTasks { - offset := task.Offset - length := task.Length - - startIdx := int(offset / ps.ActualChunkSize) - endIdx := int((offset + length - 1) / ps.ActualChunkSize) - - if startIdx < 0 { - startIdx = 0 - } - if endIdx >= ps.BitmapWidth { - endIdx = ps.BitmapWidth - 1 - } - - for i := startIdx; i <= endIdx; i++ { - chunkStart := int64(i) * ps.ActualChunkSize - chunkEnd := chunkStart + ps.ActualChunkSize - if chunkEnd > ps.TotalSize { - chunkEnd = ps.TotalSize - } - - taskStart := offset - if taskStart < chunkStart { - taskStart = chunkStart - } - - taskEnd := offset + length - if taskEnd > chunkEnd { - taskEnd = chunkEnd - } - - overlap := taskEnd - taskStart - if overlap > 0 { - ps.ChunkProgress[i] -= overlap - totalVerified -= overlap - } - } - } - - ps.VerifiedProgress.Store(totalVerified) - - for i := 0; i < ps.BitmapWidth; i++ { - chunkStart := int64(i) * ps.ActualChunkSize - chunkEnd := chunkStart + ps.ActualChunkSize - if chunkEnd > ps.TotalSize { - chunkEnd = ps.TotalSize - } - chunkSize := chunkEnd - chunkStart - - if ps.ChunkProgress[i] >= chunkSize { - ps.ChunkProgress[i] = chunkSize - ps.setChunkState(i, ChunkCompleted) - } else if ps.ChunkProgress[i] > 0 { - ps.setChunkState(i, ChunkDownloading) - } else { - ps.ChunkProgress[i] = 0 - ps.setChunkState(i, ChunkPending) - } - } -} - -// GetBitmap returns a copy of the bitmap and metadata -func (ps *ProgressState) GetBitmap() ([]byte, int, int64, int64, []int64) { - return ps.GetBitmapSnapshot(true) -} - -// GetBitmapSnapshot returns a copy of bitmap metadata and optionally chunk progress. -func (ps *ProgressState) GetBitmapSnapshot(includeProgress bool) ([]byte, int, int64, int64, []int64) { - ps.mu.Lock() - defer ps.mu.Unlock() - - if len(ps.ChunkBitmap) == 0 { - return nil, 0, 0, 0, nil - } - - result := make([]byte, len(ps.ChunkBitmap)) - copy(result, ps.ChunkBitmap) - - var progressResult []int64 - if includeProgress { - progressResult = make([]int64, len(ps.ChunkProgress)) - copy(progressResult, ps.ChunkProgress) - } - - return result, ps.BitmapWidth, ps.TotalSize, ps.ActualChunkSize, progressResult -} diff --git a/internal/processing/duplicate.go b/internal/orchestrator/duplicate.go similarity index 79% rename from internal/processing/duplicate.go rename to internal/orchestrator/duplicate.go index 165311c76..9a8503a21 100644 --- a/internal/processing/duplicate.go +++ b/internal/orchestrator/duplicate.go @@ -1,10 +1,11 @@ -package processing +package orchestrator import ( "strings" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" ) // DuplicateResult represents the outcome of a duplicate check @@ -20,7 +21,7 @@ type DuplicateResult struct { // Policy decisions (whether to warn, block, or auto-approve) are the caller's // responsibility. This separation is required so that headless mode can always // distinguish duplicates from new downloads, even when WarnOnDuplicate is off. -func CheckForDuplicate(url string, activeDownloads func() map[string]*types.DownloadConfig) *DuplicateResult { +func CheckForDuplicate(url string, activeDownloads func() map[string]*types.DownloadRecord) *DuplicateResult { normalizedInputURL := strings.TrimRight(url, "/") // Check active downloads @@ -30,7 +31,7 @@ func CheckForDuplicate(url string, activeDownloads func() map[string]*types.Down normalizedExistingURL := strings.TrimRight(d.URL, "/") if normalizedExistingURL == normalizedInputURL { isActive := false - if d.State != nil && !d.State.Done.Load() { + if d.ProgressState != nil && !progress.CfgProgress(d).Done.Load() { isActive = true } @@ -45,7 +46,7 @@ func CheckForDuplicate(url string, activeDownloads func() map[string]*types.Down } // Check persisted completed/paused/queued entries in DB. - if exists, err := state.CheckDownloadExists(normalizedInputURL); err == nil && exists { + if exists, err := store.CheckDownloadExists(normalizedInputURL); err == nil && exists { return &DuplicateResult{ Exists: true, IsActive: false, diff --git a/internal/orchestrator/event_bus.go b/internal/orchestrator/event_bus.go new file mode 100644 index 000000000..c8b91f218 --- /dev/null +++ b/internal/orchestrator/event_bus.go @@ -0,0 +1,150 @@ +package orchestrator + +import ( + "context" + "sync" + "time" + + "github.com/SurgeDM/Surge/internal/types" + "github.com/SurgeDM/Surge/internal/utils" +) + +// EventBus handles broadcasting events from the orchestrator to all listeners. +type EventBus struct { + InputCh chan types.DownloadEvent + listeners []chan types.DownloadEvent + listenerMu sync.Mutex + unsubscribeCh chan chan types.DownloadEvent + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + pubWg sync.WaitGroup +} + +func NewEventBus() *EventBus { + ctx, cancel := context.WithCancel(context.Background()) + eb := &EventBus{ + InputCh: make(chan types.DownloadEvent, 100), + listeners: make([]chan types.DownloadEvent, 0), + unsubscribeCh: make(chan chan types.DownloadEvent, 10), + ctx: ctx, + cancel: cancel, + } + eb.wg.Add(1) + go eb.broadcastLoop() + return eb +} + +func (eb *EventBus) broadcastLoop() { + defer eb.wg.Done() + for { + select { + case msg, ok := <-eb.InputCh: + if !ok { + eb.listenerMu.Lock() + for _, ch := range eb.listeners { + close(ch) + } + eb.listeners = nil + eb.listenerMu.Unlock() + return + } + eb.broadcastMsg(msg) + + case chToClose := <-eb.unsubscribeCh: + eb.listenerMu.Lock() + for i, listener := range eb.listeners { + if listener == chToClose { + eb.listeners = append(eb.listeners[:i], eb.listeners[i+1:]...) + close(chToClose) + break + } + } + eb.listenerMu.Unlock() + } + } +} + +func (eb *EventBus) broadcastMsg(msg types.DownloadEvent) { + eb.listenerMu.Lock() + listenersCopy := make([]chan types.DownloadEvent, len(eb.listeners)) + copy(listenersCopy, eb.listeners) + eb.listenerMu.Unlock() + + isProgress := msg.Type == types.EventProgress || msg.Type == types.EventBatchProgress + + for _, ch := range listenersCopy { + func() { + defer func() { _ = recover() }() + if isProgress { + select { + case ch <- msg: + default: + } + } else { + timer := time.NewTimer(1 * time.Second) + select { + case ch <- msg: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + case <-timer.C: + utils.Debug("Dropped critical event due to slow client") + } + } + }() + } +} + +// Publish emits an event into the bus. +func (eb *EventBus) Publish(msg types.DownloadEvent) error { + eb.pubWg.Add(1) + defer eb.pubWg.Done() + + // Prevent sending on a closed channel by checking if shutdown has started. + // Because Shutdown() calls cancel() before pubWg.Wait(), if this check + // passes, we guarantee that close(InputCh) cannot happen until we Done(). + select { + case <-eb.ctx.Done(): + return context.Canceled + default: + } + + select { + case <-eb.ctx.Done(): + return context.Canceled + case eb.InputCh <- msg: + return nil + case <-time.After(1 * time.Second): + return context.DeadlineExceeded + } +} + +// Subscribe returns a channel that receives events. +func (eb *EventBus) Subscribe() (<-chan types.DownloadEvent, func()) { + outCh := make(chan types.DownloadEvent, 100) + eb.listenerMu.Lock() + eb.listeners = append(eb.listeners, outCh) + eb.listenerMu.Unlock() + + var once sync.Once + cleanup := func() { + once.Do(func() { + select { + case eb.unsubscribeCh <- outCh: + case <-eb.ctx.Done(): + } + }) + } + return outCh, cleanup +} + +func (eb *EventBus) Shutdown() { + eb.cancel() + eb.pubWg.Wait() // wait for all active Publish calls to return + close(eb.InputCh) // safely close to trigger drain + eb.wg.Wait() +} diff --git a/internal/orchestrator/event_bus_test.go b/internal/orchestrator/event_bus_test.go new file mode 100644 index 000000000..19cc94e0a --- /dev/null +++ b/internal/orchestrator/event_bus_test.go @@ -0,0 +1,180 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/SurgeDM/Surge/internal/types" +) + +func TestEventBus_BasicPubSub(t *testing.T) { + eb := NewEventBus() + defer eb.Shutdown() + + sub, cleanup := eb.Subscribe() + defer cleanup() + + msg := types.DownloadEvent{Message: "test message"} + err := eb.Publish(msg) + if err != nil { + t.Fatalf("expected nil error on publish, got %v", err) + } + + select { + case received := <-sub: + if received.Message != msg.Message { + t.Errorf("expected %v, got %v", msg, received) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for event") + } +} + +func TestEventBus_MultipleSubscribers(t *testing.T) { + eb := NewEventBus() + defer eb.Shutdown() + + sub1, cleanup1 := eb.Subscribe() + defer cleanup1() + + sub2, cleanup2 := eb.Subscribe() + defer cleanup2() + + msg := types.DownloadEvent{Message: "broadcast"} + _ = eb.Publish(msg) + + for i, sub := range []<-chan types.DownloadEvent{sub1, sub2} { + select { + case received := <-sub: + if received.Message != msg.Message { + t.Errorf("subscriber %d expected %v, got %v", i+1, msg, received) + } + case <-time.After(500 * time.Millisecond): + t.Fatalf("subscriber %d timed out", i+1) + } + } +} + +func TestEventBus_ProgressMsgDropBehavior(t *testing.T) { + eb := NewEventBus() + defer eb.Shutdown() + + // Subscriber that doesn't read from the channel (will block quickly) + _, cleanup := eb.Subscribe() + defer cleanup() + + // Fill the buffer (size 100 for outCh) by publishing 100 items + for i := 0; i < 100; i++ { + _ = eb.Publish(types.DownloadEvent{}) + } + + // Give the broadcast loop time to fill the subscriber's channel buffer + time.Sleep(100 * time.Millisecond) + + // Publish a progress message. It should be dropped immediately without blocking for 1s. + start := time.Now() + msg := types.DownloadEvent{ + Type: types.EventProgress, DownloadID: "test"} + _ = eb.Publish(msg) + + // Since it's a progress message and the subscriber channel is full, it should drop it and return quickly. + // Wait a little bit to ensure broadcastLoop processed it. + time.Sleep(50 * time.Millisecond) + elapsed := time.Since(start) + + if elapsed >= 1*time.Second { + t.Errorf("publish of ProgressMsg took too long (%v), it should be dropped immediately", elapsed) + } +} + +func TestEventBus_CriticalMsgWaitBehavior(t *testing.T) { + eb := NewEventBus() + defer eb.Shutdown() + + // Subscriber that doesn't read + _, cleanup := eb.Subscribe() + defer cleanup() + + // Fill buffer + for i := 0; i < 100; i++ { + _ = eb.Publish(types.DownloadEvent{}) + } + + // Give the broadcast loop time to fill the subscriber's channel buffer + time.Sleep(100 * time.Millisecond) + + // Publish a critical message (not progress). It should block for up to 1 second before dropping. + start := time.Now() + msg := types.DownloadEvent{Message: "critical"} + _ = eb.Publish(msg) + + // Wait for processing + time.Sleep(1200 * time.Millisecond) + elapsed := time.Since(start) + + // broadcastLoop should take at least 1s to try sending to a blocked subscriber + if elapsed < 1*time.Second { + t.Errorf("broadcast loop did not wait 1s for critical message, elapsed: %v", elapsed) + } +} + +func TestEventBus_ShutdownCleanly(t *testing.T) { + eb := NewEventBus() + + sub, cleanup := eb.Subscribe() + defer cleanup() + + eb.Shutdown() + + // Should not be able to publish after shutdown + err := eb.Publish(types.DownloadEvent{}) + if !errors.Is(err, context.Canceled) { + t.Errorf("expected context.Canceled on publish after shutdown, got %v", err) + } + + // Channels should be closed + select { + case _, ok := <-sub: + if ok { + t.Error("expected channel to be closed after shutdown") + } + case <-time.After(100 * time.Millisecond): + t.Error("timed out waiting for subscriber channel to close") + } +} + +func TestEventBus_Unsubscribe(t *testing.T) { + eb := NewEventBus() + defer eb.Shutdown() + + _, cleanup := eb.Subscribe() + + eb.listenerMu.Lock() + count := len(eb.listeners) + eb.listenerMu.Unlock() + if count != 1 { + t.Fatalf("expected 1 listener, got %d", count) + } + + cleanup() + + // Wait for the asynchronous unsubscribe to be processed by broadcastLoop + for i := 0; i < 10; i++ { + eb.listenerMu.Lock() + count = len(eb.listeners) + eb.listenerMu.Unlock() + if count == 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if count != 0 { + t.Fatalf("expected 0 listeners after cleanup, got %d", count) + } + + // Should be safe to call cleanup multiple times (sync.Once) + cleanup() +} diff --git a/internal/processing/events.go b/internal/orchestrator/events.go similarity index 78% rename from internal/processing/events.go rename to internal/orchestrator/events.go index 788dce8fb..78c65bdfa 100644 --- a/internal/processing/events.go +++ b/internal/orchestrator/events.go @@ -1,4 +1,4 @@ -package processing +package orchestrator import ( "errors" @@ -8,9 +8,8 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -73,17 +72,17 @@ func finalizeCompletedFile(finalPath string) error { // StartEventWorker listens to engine events and handles database persistence // and file cleanup, ensuring the core engine remains stateless. -func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { +func (mgr *LifecycleManager) StartEventWorker(ch <-chan types.DownloadEvent) { for msg := range ch { - switch m := msg.(type) { - - case events.DownloadStartedMsg: + m := msg + switch m.Type { + case types.EventStarted: // Persist the started record immediately so crash recovery and later lifecycle // events have a stable destination record even before the first pause snapshot. - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: m.DownloadID, URL: m.URL, - URLHash: state.URLHash(m.URL), + URLHash: store.URLHash(m.URL), DestPath: m.DestPath, Filename: m.Filename, Status: "downloading", @@ -94,7 +93,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { Workers: m.Workers, MinChunkSize: m.MinChunkSize, } - if existing, _ := state.GetDownload(m.DownloadID); existing != nil { + if existing, _ := store.GetDownload(m.DownloadID); existing != nil { entry.Mirrors = append([]string(nil), existing.Mirrors...) if existing.Downloaded > 0 { entry.Downloaded = existing.Downloaded @@ -103,13 +102,13 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { entry.TimeTaken = existing.TimeTaken } } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { utils.Debug("Lifecycle: Failed to save initial download state: %v", err) } - case events.DownloadPausedMsg: + case types.EventPaused: if m.State == nil { - existing, _ := state.GetDownload(m.DownloadID) + existing, _ := store.GetDownload(m.DownloadID) if existing == nil { utils.Debug("Lifecycle: Skipping paused fallback for %s: no persisted entry yet", m.DownloadID) break @@ -120,12 +119,12 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { if m.Downloaded > 0 { entry.Downloaded = m.Downloaded } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { utils.Debug("Lifecycle: Failed to persist paused fallback entry: %v", err) } if existing.URL != "" && existing.DestPath != "" { - saved, err := state.LoadState(existing.URL, existing.DestPath) + saved, err := store.LoadState(existing.URL, existing.DestPath) if err == nil && saved != nil { prevDownloaded := saved.Downloaded prevElapsed := saved.Elapsed @@ -145,7 +144,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { saved.Elapsed = prevElapsed + int64(time.Millisecond) } - if err := state.SaveStateWithOptions(existing.URL, existing.DestPath, saved, state.SaveStateOptions{SkipFileHash: true}); err != nil { + if err := store.SaveStateWithOptions(existing.URL, existing.DestPath, saved, store.SaveStateOptions{SkipFileHash: true}); err != nil { utils.Debug("Lifecycle: Failed to persist paused fallback state: %v", err) } } @@ -155,11 +154,12 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { // Pause snapshots can race slightly behind the master entry, so fall back to // the DB values to keep the resume key stable when the in-memory state is sparse. - snapshot := *m.State - destPath := m.State.DestPath - url := m.State.URL + stateSnapshot := m.State + snapshot := *stateSnapshot + destPath := stateSnapshot.DestPath + url := stateSnapshot.URL - existing, _ := state.GetDownload(m.DownloadID) + existing, _ := store.GetDownload(m.DownloadID) if existing != nil { if destPath == "" { destPath = existing.DestPath @@ -176,7 +176,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { } } - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: m.DownloadID, Status: "paused", Downloaded: snapshot.Downloaded, @@ -193,7 +193,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { entry.URL = existing.URL entry.URLHash = existing.URLHash } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { utils.Debug("Lifecycle: Failed to persist paused state: %v", err) } @@ -201,7 +201,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { // destPath/url pair used everywhere else as the state DB key. if destPath != "" && url != "" { // Keep pause persistence fast so lifecycle events don't back up and get dropped. - if err := state.SaveStateWithOptions(url, destPath, &snapshot, state.SaveStateOptions{ + if err := store.SaveStateWithOptions(url, destPath, &snapshot, store.SaveStateOptions{ SkipFileHash: true, }); err != nil { utils.Debug("Lifecycle: Failed to save pause state: %v", err) @@ -210,7 +210,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { utils.Debug("Lifecycle: Skipping SaveState for %s: destPath=%q url=%q", m.DownloadID, destPath, url) } - case events.DownloadCompleteMsg: + case types.EventComplete: var avgSpeed float64 if m.Elapsed.Seconds() > 0 { avgSpeed = float64(m.Total) / m.Elapsed.Seconds() @@ -219,7 +219,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { destPath := "" // DownloadCompleteMsg does not carry destPath, so we recover the stable final // location from the DB entry written earlier on this same serialized event stream. - existing, _ := state.GetDownload(m.DownloadID) + existing, _ := store.GetDownload(m.DownloadID) var url, urlHash string filename := m.Filename if existing != nil { @@ -235,7 +235,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { // finalization failure must stay retryable instead of being recorded as done. if err := finalizeCompletedFile(destPath); err != nil { utils.Debug("Lifecycle: Failed to finalize completed file at %s: %v", destPath, err) - errEntry := types.DownloadEntry{ + errEntry := types.DownloadRecord{ ID: m.DownloadID, URL: url, URLHash: urlHash, @@ -253,7 +253,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { errEntry.Workers = existing.Workers errEntry.MinChunkSize = existing.MinChunkSize } - if err := state.AddToMasterList(errEntry); err != nil { + if err := store.AddToMasterList(errEntry); err != nil { utils.Debug("Lifecycle: Failed to persist finalization error state: %v", err) } if filename == "" { @@ -269,7 +269,7 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { break } - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: m.DownloadID, URL: url, URLHash: urlHash, @@ -288,14 +288,13 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { entry.Workers = existing.Workers entry.MinChunkSize = existing.MinChunkSize } - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { utils.Debug("Lifecycle: Failed to persist completed download: %v", err) } - if err := state.DeleteTasks(m.DownloadID); err != nil { + if err := store.DeleteTasks(m.DownloadID); err != nil { utils.Debug("Lifecycle: Failed to delete completed tasks: %v", err) } if settings := mgr.GetSettings(); settings != nil && config.Resolve[bool](settings.General.DownloadCompleteNotification) { - if filename == "" { filename = m.Filename } @@ -312,12 +311,12 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { } } - case events.DownloadErrorMsg: - existing, _ := state.GetDownload(m.DownloadID) + case types.EventError: + existing, _ := store.GetDownload(m.DownloadID) destPath := m.DestPath if existing != nil { existing.Status = "error" - if err := state.AddToMasterList(*existing); err != nil { + if err := store.AddToMasterList(*existing); err != nil { utils.Debug("Lifecycle: Failed to persist error state: %v", err) } if existing.DestPath != "" { @@ -330,7 +329,6 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { } } if settings := mgr.GetSettings(); settings != nil && config.Resolve[bool](settings.General.DownloadCompleteNotification) { - filename := m.Filename if filename == "" && existing != nil { filename = existing.Filename @@ -347,12 +345,12 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { notify(fmt.Sprintf("Download failed: %s", filename), msg) } - case events.DownloadRemovedMsg: + case types.EventRemoved: // Remove resume metadata before touching files so a deleted download does not // come back during startup recovery. DeleteState atomically removes both the // detail gob and the master list entry, so no separate RemoveFromMasterList call // is needed. - if err := state.DeleteState(m.DownloadID); err != nil { + if err := store.DeleteState(m.DownloadID); err != nil { utils.Debug("Lifecycle: Failed to delete state: %v", err) } @@ -364,26 +362,33 @@ func (mgr *LifecycleManager) StartEventWorker(ch <-chan interface{}) { } } - case events.DownloadQueuedMsg: - // Queue persistence is what lets downloads survive shutdown before any worker - // has emitted a started event. - if err := state.AddToMasterList(types.DownloadEntry{ - ID: m.DownloadID, - URL: m.URL, - URLHash: state.URLHash(m.URL), - DestPath: m.DestPath, - Filename: m.Filename, - Mirrors: append([]string(nil), m.Mirrors...), - Status: "queued", - RateLimit: m.RateLimit, - RateLimitSet: m.RateLimitSet, - Workers: m.Workers, - MinChunkSize: m.MinChunkSize, - }); err != nil { - utils.Debug("Lifecycle: Failed to persist queued download: %v", err) + case types.EventQueued: + // enqueueResolved already persisted this record synchronously (status="queued") + // before pool.Add, so the entry normally exists by the time we arrive here. + // Only write if absent to avoid regressing an already-advanced status + // (e.g. "downloading") when EventStarted races ahead of this event. + if existing, _ := store.GetDownload(m.DownloadID); existing == nil { + if err := store.AddToMasterList(types.DownloadRecord{ + ID: m.DownloadID, + URL: m.URL, + URLHash: store.URLHash(m.URL), + DestPath: m.DestPath, + Filename: m.Filename, + Mirrors: append([]string(nil), m.Mirrors...), + Status: "queued", + RateLimit: m.RateLimit, + RateLimitSet: m.RateLimitSet, + Workers: m.Workers, + MinChunkSize: m.MinChunkSize, + }); err != nil { + utils.Debug("Lifecycle: Failed to persist queued download: %v", err) + } } - case events.BatchProgressMsg, events.ProgressMsg: + case types.EventResumed, types.EventRequest, types.EventBatchRequest, types.EventSystem: + // These events require no persistence in the lifecycle worker. + + case types.EventBatchProgress, types.EventProgress: // Progress ticks are intentionally transient; persisting them would add // file I/O churn without improving resume or history recovery. } diff --git a/internal/processing/file_other.go b/internal/orchestrator/file_other.go similarity index 94% rename from internal/processing/file_other.go rename to internal/orchestrator/file_other.go index 6f7c72747..a2f8f9390 100644 --- a/internal/processing/file_other.go +++ b/internal/orchestrator/file_other.go @@ -1,6 +1,6 @@ //go:build !windows -package processing +package orchestrator import "os" diff --git a/internal/processing/file_utils.go b/internal/orchestrator/file_utils.go similarity index 98% rename from internal/processing/file_utils.go rename to internal/orchestrator/file_utils.go index aa21a72a5..41d4c6858 100644 --- a/internal/processing/file_utils.go +++ b/internal/orchestrator/file_utils.go @@ -1,4 +1,4 @@ -package processing +package orchestrator import ( "fmt" @@ -10,8 +10,8 @@ import ( "strings" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/types" probing "github.com/SurgeDM/Surge/internal/probe" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) diff --git a/internal/processing/file_utils_test.go b/internal/orchestrator/file_utils_test.go similarity index 76% rename from internal/processing/file_utils_test.go rename to internal/orchestrator/file_utils_test.go index 2dfabc020..bb653e3b9 100644 --- a/internal/processing/file_utils_test.go +++ b/internal/orchestrator/file_utils_test.go @@ -1,4 +1,4 @@ -package processing_test +package orchestrator import ( "os" @@ -8,9 +8,8 @@ import ( "testing" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/types" "github.com/SurgeDM/Surge/internal/probe" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/types" ) func TestInferFilenameFromURL(t *testing.T) { @@ -27,7 +26,7 @@ func TestInferFilenameFromURL(t *testing.T) { } for _, tt := range tests { - actual := processing.InferFilenameFromURL(tt.url) + actual := InferFilenameFromURL(tt.url) if actual != tt.expected { t.Errorf("InferFilenameFromURL(%q) = %q; want %q", tt.url, actual, tt.expected) } @@ -38,7 +37,7 @@ func TestGetUniqueFilename(t *testing.T) { tmpDir := t.TempDir() // 1. Doesn't exist - if name := processing.GetUniqueFilename(tmpDir, "test.txt", nil); name != "test.txt" { + if name := GetUniqueFilename(tmpDir, "test.txt", nil); name != "test.txt" { t.Errorf("Expected test.txt, got %s", name) } @@ -46,7 +45,7 @@ func TestGetUniqueFilename(t *testing.T) { if err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("data"), 0o644); err != nil { t.Fatalf("failed to create test file: %v", err) } - if name := processing.GetUniqueFilename(tmpDir, "test.txt", nil); name != "test(1).txt" { + if name := GetUniqueFilename(tmpDir, "test.txt", nil); name != "test(1).txt" { t.Errorf("Expected test(1).txt, got %s", name) } @@ -54,7 +53,7 @@ func TestGetUniqueFilename(t *testing.T) { if err := os.WriteFile(filepath.Join(tmpDir, "partial.zip"+types.IncompleteSuffix), []byte("data"), 0o644); err != nil { t.Fatalf("failed to create partial file: %v", err) } - if name := processing.GetUniqueFilename(tmpDir, "partial.zip", nil); name != "partial(1).zip" { + if name := GetUniqueFilename(tmpDir, "partial.zip", nil); name != "partial(1).zip" { t.Errorf("Expected partial(1).zip, got %s", name) } @@ -62,7 +61,7 @@ func TestGetUniqueFilename(t *testing.T) { activeDownloads := func(dir, name string) bool { return dir == tmpDir && name == "memory.bin" } - if name := processing.GetUniqueFilename(tmpDir, "memory.bin", activeDownloads); name != "memory(1).bin" { + if name := GetUniqueFilename(tmpDir, "memory.bin", activeDownloads); name != "memory(1).bin" { t.Errorf("Expected memory(1).bin, got %s", name) } @@ -74,7 +73,7 @@ func TestGetUniqueFilename(t *testing.T) { dirAwareActive := func(dir, name string) bool { return dir == otherDir && name == "video.mp4" } - if name := processing.GetUniqueFilename(tmpDir, "video.mp4", dirAwareActive); name != "video.mp4" { + if name := GetUniqueFilename(tmpDir, "video.mp4", dirAwareActive); name != "video.mp4" { t.Errorf("Expected video.mp4, got %s", name) } @@ -93,7 +92,7 @@ func TestGetUniqueFilename(t *testing.T) { } return false } - if name := processing.GetUniqueFilename(tmpDir, "overflow.bin", overflowActive); name != "" { + if name := GetUniqueFilename(tmpDir, "overflow.bin", overflowActive); name != "" { t.Errorf("Expected empty result after exhaustion, got %s", name) } } @@ -112,7 +111,7 @@ func TestGetCategoryPath(t *testing.T) { } // Match - path, err := processing.GetCategoryPath("test.jpg", tmpDir, settings) + path, err := GetCategoryPath("test.jpg", tmpDir, settings) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -122,7 +121,7 @@ func TestGetCategoryPath(t *testing.T) { } // No Match - path, err = processing.GetCategoryPath("test.txt", tmpDir, settings) + path, err = GetCategoryPath("test.txt", tmpDir, settings) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -132,7 +131,7 @@ func TestGetCategoryPath(t *testing.T) { // Disabled settings.Categories.CategoryEnabled.Value = false - path, err = processing.GetCategoryPath("test.jpg", tmpDir, settings) + path, err = GetCategoryPath("test.jpg", tmpDir, settings) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -150,7 +149,7 @@ func TestGetCategoryPath(t *testing.T) { Path: missingDir, }, } - path, err = processing.GetCategoryPath("tool.bin", tmpDir, settings) + path, err = GetCategoryPath("tool.bin", tmpDir, settings) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -168,32 +167,32 @@ func TestResolveDestination_Priority(t *testing.T) { defaultDir := "/downloads" // 1. User defined beats all - _, name, _ := processing.ResolveDestination("http://example.com/file.zip", "user.txt", defaultDir, false, settings, &probe.ProbeResult{DetectedFilename: "probe.zip"}, nil) + _, name, _ := ResolveDestination("http://example.com/file.zip", "user.txt", defaultDir, false, settings, &probe.ProbeResult{DetectedFilename: "probe.zip"}, nil) if name != "user.txt" { t.Errorf("Expected user.txt as candidate priority, got %s", name) } // 2. Probe beats URL fallback - _, name, _ = processing.ResolveDestination("http://example.com/file.zip", "", defaultDir, false, settings, &probe.ProbeResult{DetectedFilename: "probe.zip"}, nil) + _, name, _ = ResolveDestination("http://example.com/file.zip", "", defaultDir, false, settings, &probe.ProbeResult{DetectedFilename: "probe.zip"}, nil) if name != "probe.zip" { t.Errorf("Expected probe.zip, got %s", name) } // 3. URL Fallback when probe is nil - _, name, _ = processing.ResolveDestination("http://example.com/another.tar.gz", "", defaultDir, false, settings, nil, nil) + _, name, _ = ResolveDestination("http://example.com/another.tar.gz", "", defaultDir, false, settings, nil, nil) if name != "another.tar.gz" { t.Errorf("Expected another.tar.gz, got %s", name) } // 4. URL Fallback when probe has empty filename - _, name, _ = processing.ResolveDestination("http://example.com/some.rar", "", defaultDir, false, settings, &probe.ProbeResult{DetectedFilename: ""}, nil) + _, name, _ = ResolveDestination("http://example.com/some.rar", "", defaultDir, false, settings, &probe.ProbeResult{DetectedFilename: ""}, nil) if name != "some.rar" { t.Errorf("Expected some.rar, got %s", name) } // 5. Long candidate hint is preserved (but truncated) longHint := "WrTLulKik3KpjnMuO-0gDohCI1WaybS779E_l6yr1UHGRMFfTkE7B5t5Ys5_N2qu8u6HmpGsrEZKftnkvhgxcvRqn6Pp9kceoiJRSTvPjlX8oDQW70mjRG9HlCYBmFoOYLJ7t133YpIR5xQXdPT8QWAMMUNyp6K3jeNJ3YmAez-_9MdrMBv6HlBRmDwSBwrubB895P34XJ40NUUmb6t0ITGTyZVc3kUBZ_emFeD-h8m-S--dzrdsyXdUQGI0amVV7cMetT2bifXVgzJaFn4mGZAvs7bIwe63xm1ARB2jF4hQ0V0hq8Db_6F4yH4_37XoubaVenavjGN5gW3uR2_FLFGc5JCMtlRwsBvF9wxcLTpWvn9IW61s-1aiAHnUbL9eesMzzY5DLXXgTkxTDre21UP4L6kNymwWFhjdbFzxIzBg_Z1RzzIzXVSYXu1O71Hvpu_FSW4N881BlaIZNCZPPtqqDrSwq3wdYECu_Sm1WxQ3kZOU9wjNu_03YHlpsYTm8lLK3EsxVGSgmpiLxLS4XI7lqVWI1_20Lkako4spInGKQYkq-E2S6k6opM58WLuz3-DyW0s-BTpUWPvYoazIc3eY_f4bCmy3uXsZ165iukgHvOnS6_ruKFw3kQMDuZVe" - _, name, _ = processing.ResolveDestination("http://example.com/"+longHint, longHint, defaultDir, false, settings, &probe.ProbeResult{DetectedFilename: "correct.rar"}, nil) + _, name, _ = ResolveDestination("http://example.com/"+longHint, longHint, defaultDir, false, settings, &probe.ProbeResult{DetectedFilename: "correct.rar"}, nil) if name == "correct.rar" { t.Errorf("Expected longHint to be used because heuristic was removed, but got correct.rar") } @@ -203,7 +202,7 @@ func TestResolveDestination_Priority(t *testing.T) { // 6. Universal truncation in ResolveDestination veryLongName := "this_is_a_very_long_filename_that_exceeds_the_limit_of_two_hundred_and_forty_characters_to_ensure_the_truncation_logic_is_working_correctly_at_the_destination_resolution_level_and_not_just_at_the_probing_utility_level_which_was_the_previous_bug.zip" - _, name, _ = processing.ResolveDestination("http://example.com/long", veryLongName, defaultDir, false, settings, nil, nil) + _, name, _ = ResolveDestination("http://example.com/long", veryLongName, defaultDir, false, settings, nil, nil) if len(name) > 240 { t.Errorf("Expected filename to be truncated to <= 240 chars, got length %d", len(name)) } @@ -228,7 +227,7 @@ func TestResolveDestination_ErrorsWhenUniqueNameExhausted(t *testing.T) { return false } - _, _, err := processing.ResolveDestination( + _, _, err := ResolveDestination( "http://example.com/overflow.bin", "overflow.bin", "/downloads", diff --git a/internal/processing/file_windows.go b/internal/orchestrator/file_windows.go similarity index 98% rename from internal/processing/file_windows.go rename to internal/orchestrator/file_windows.go index 921f18073..b8ec7a1b8 100644 --- a/internal/processing/file_windows.go +++ b/internal/orchestrator/file_windows.go @@ -1,6 +1,6 @@ //go:build windows -package processing +package orchestrator import ( "os" diff --git a/internal/processing/manager.go b/internal/orchestrator/manager.go similarity index 65% rename from internal/processing/manager.go rename to internal/orchestrator/manager.go index a2d9ba299..fc156bfb8 100644 --- a/internal/processing/manager.go +++ b/internal/orchestrator/manager.go @@ -1,4 +1,4 @@ -package processing +package orchestrator import ( "context" @@ -11,22 +11,20 @@ import ( "sync" "time" + "github.com/google/uuid" + "net/url" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" probing "github.com/SurgeDM/Surge/internal/probe" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" + "github.com/SurgeDM/Surge/internal/scheduler" "github.com/SurgeDM/Surge/internal/utils" ) -// AddDownloadFunc is the lifecycle's handoff into the engine-facing queue layer. -type AddDownloadFunc func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) - -// AddDownloadWithIDFunc preserves caller-chosen ids when a remote/UI layer already owns them. -type AddDownloadWithIDFunc func(string, string, string, []string, map[string]string, string, int, int64, int64, bool) (string, error) - // IsNameActiveFunc lets routing treat in-flight downloads as filename conflicts within a directory. type IsNameActiveFunc func(dir, name string) bool @@ -34,11 +32,11 @@ type LifecycleManager struct { settings *config.Settings settingsMu sync.RWMutex settingsRefreshedAt time.Time - addFunc AddDownloadFunc - addWithIDFunc AddDownloadWithIDFunc + pool *scheduler.Scheduler + eventBus *EventBus + aggregator *ProgressAggregator isNameActive IsNameActiveFunc - engineHooks EngineHooks - hooksMu sync.RWMutex + // probeSem caps the number of simultaneous server probes so adding a // large batch of downloads does not flood the network with HEAD requests. probeSem chan struct{} @@ -50,9 +48,6 @@ const ( // no settings value is available. The live value comes from // NetworkSettings.MaxConcurrentProbes. defaultMaxConcurrentProbes = 3 - // maxConcurrentProbes is the package-level cap used by tests that construct - // a manager without a settings snapshot (newLifecycleManagerForTest). - maxConcurrentProbes = defaultMaxConcurrentProbes ) var settingsRefreshTTL = time.Second @@ -84,7 +79,7 @@ func (mgr *LifecycleManager) buildIsNameActive() func(string, string) bool { return func(string, string) bool { return false } } -func NewLifecycleManager(addFunc AddDownloadFunc, addWithIDFunc AddDownloadWithIDFunc, isNameActive ...IsNameActiveFunc) *LifecycleManager { +func NewLifecycleManager(pool *scheduler.Scheduler, eventBus *EventBus, isNameActive ...IsNameActiveFunc) *LifecycleManager { // Snapshot settings once so enqueue can still make routing decisions even if // a later disk read fails or the caller never opens the settings UI. settings, err := config.LoadSettings() @@ -106,29 +101,42 @@ func NewLifecycleManager(addFunc AddDownloadFunc, addWithIDFunc AddDownloadWithI sem <- struct{}{} } + var aggregator *ProgressAggregator + if pool != nil && eventBus != nil { + aggregator = NewProgressAggregator(pool, eventBus, settings) + } + return &LifecycleManager{ settings: settings, settingsRefreshedAt: time.Now(), - addFunc: addFunc, - addWithIDFunc: addWithIDFunc, + pool: pool, + eventBus: eventBus, + aggregator: aggregator, isNameActive: activeCheck, probeSem: sem, } } -// SetEngineHooks injects dependencies the manager needs to interact with the broader system -// (like the download worker pool or the event system) without causing cyclic dependency graphs. -func (mgr *LifecycleManager) SetEngineHooks(hooks EngineHooks) { - mgr.hooksMu.Lock() - defer mgr.hooksMu.Unlock() - mgr.engineHooks = hooks +// GetScheduler returns the underlying scheduler +func (mgr *LifecycleManager) GetScheduler() *scheduler.Scheduler { + return mgr.pool } -// getEngineHooks safely returns the current engine hooks. -func (mgr *LifecycleManager) getEngineHooks() EngineHooks { - mgr.hooksMu.RLock() - defer mgr.hooksMu.RUnlock() - return mgr.engineHooks +// GetEventBus returns the event bus +func (mgr *LifecycleManager) GetEventBus() *EventBus { + return mgr.eventBus +} + +func (mgr *LifecycleManager) Shutdown() { + if mgr.aggregator != nil { + mgr.aggregator.Shutdown() + } + if mgr.pool != nil { + mgr.pool.GracefulShutdown() + } + if mgr.eventBus != nil { + mgr.eventBus.Shutdown() + } } // GetSettings reloads disk-backed routing rules opportunistically so a long-lived @@ -198,53 +206,25 @@ type DownloadRequest struct { // Enqueue probes and reserves a stable destination before dispatching to the queue layer. func (mgr *LifecycleManager) Enqueue(ctx context.Context, req *DownloadRequest) (string, string, error) { - if mgr.addFunc == nil { + if mgr.pool == nil { return "", "", types.ErrServiceUnavailable } utils.Debug("Lifecycle: Enqueue %s (Filename: %s)", req.URL, req.Filename) - return mgr.enqueueResolved(ctx, req, func(finalPath, finalFilename string, probeResult *probing.ProbeResult) (string, error) { - return mgr.addFunc( - req.URL, - finalPath, - finalFilename, - req.Mirrors, - req.Headers, - req.IsExplicitCategory, - req.Workers, - req.MinChunkSize, - probeResult.FileSize, - probeResult.SupportsRange, - ) - }) + return mgr.enqueueResolved(ctx, req, "") } // EnqueueWithID does the same lifecycle work as Enqueue while preserving a caller-owned id. func (mgr *LifecycleManager) EnqueueWithID(ctx context.Context, req *DownloadRequest, requestID string) (string, string, error) { - if mgr.addWithIDFunc == nil { + if mgr.pool == nil { return "", "", types.ErrServiceUnavailable } utils.Debug("Lifecycle: EnqueueWithID %s (%s)", req.URL, requestID) - return mgr.enqueueResolved(ctx, req, func(finalPath, finalFilename string, probeResult *probing.ProbeResult) (string, error) { - return mgr.addWithIDFunc( - req.URL, - finalPath, - finalFilename, - req.Mirrors, - req.Headers, - requestID, - req.Workers, - req.MinChunkSize, - probeResult.FileSize, - probeResult.SupportsRange, - ) - }) + return mgr.enqueueResolved(ctx, req, requestID) } -// enqueueResolved prepares the final path and working file before handing the -// download to the engine, so workers and lifecycle events agree on one stable destination. -func (mgr *LifecycleManager) enqueueResolved(ctx context.Context, req *DownloadRequest, dispatch func(string, string, *probing.ProbeResult) (string, error)) (string, string, error) { +func (mgr *LifecycleManager) enqueueResolved(ctx context.Context, req *DownloadRequest, requestID string) (string, string, error) { if req.URL == "" { return "", "", types.ErrURLRequired } @@ -328,43 +308,51 @@ func (mgr *LifecycleManager) enqueueResolved(ctx context.Context, req *DownloadR } surgePath := filepath.Join(finalPath, finalFilename) + types.IncompleteSuffix - newID, err := dispatch(finalPath, finalFilename, probeResult) + + cfg, err := mgr.buildDownloadRecord(req, requestID, finalPath, finalFilename, probeResult) if err != nil { _ = os.Remove(surgePath) return "", "", err } - // Emit queued event now that the pool has accepted the download. - // The event worker persists this to DB so it survives a crash before the - // worker emits a started event. - hooks := mgr.getEngineHooks() - if hooks.PublishEvent != nil { - var rateLimit int64 - var rateLimitSet bool - if hooks.GetStatus != nil { - if st := hooks.GetStatus(newID); st != nil { - rateLimit = st.RateLimit - rateLimitSet = st.RateLimitSet - } - } else if settings != nil && settings.Network.DefaultDownloadRateLimit != nil { - if parsed, err := utils.ParseRateLimitValue(settings.Network.DefaultDownloadRateLimit.Value); err == nil { - rateLimit = parsed - } - } - _ = hooks.PublishEvent(events.DownloadQueuedMsg{ - DownloadID: newID, - Filename: finalFilename, - URL: req.URL, - DestPath: filepath.Join(finalPath, finalFilename), - Mirrors: append([]string(nil), req.Mirrors...), - RateLimit: rateLimit, - RateLimitSet: rateLimitSet, - Workers: req.Workers, - MinChunkSize: req.MinChunkSize, - }) + queuedEvent := types.DownloadEvent{ + Type: types.EventQueued, + DownloadID: cfg.ID, + Filename: finalFilename, + URL: req.URL, + DestPath: filepath.Join(finalPath, finalFilename), + Mirrors: append([]string(nil), req.Mirrors...), + RateLimit: cfg.RateLimit, + RateLimitSet: cfg.RateLimitSet, + Workers: req.Workers, + MinChunkSize: req.MinChunkSize, + } + // Persist synchronously before publishing so the download survives a restart + // even if the event bus is full and Publish returns DeadlineExceeded. + // Doing this BEFORE pool.Add prevents EventStarted from racing with this + // persistence and corrupting the status to "queued" if it starts instantly. + if err := store.AddToMasterList(types.DownloadRecord{ + ID: queuedEvent.DownloadID, + URL: queuedEvent.URL, + URLHash: store.URLHash(queuedEvent.URL), + DestPath: queuedEvent.DestPath, + Filename: queuedEvent.Filename, + Mirrors: append([]string(nil), queuedEvent.Mirrors...), + Status: "queued", + RateLimit: queuedEvent.RateLimit, + RateLimitSet: queuedEvent.RateLimitSet, + Workers: queuedEvent.Workers, + MinChunkSize: queuedEvent.MinChunkSize, + }); err != nil { + utils.Debug("Lifecycle: Failed to persist queued download synchronously: %v", err) + } + if mgr.eventBus != nil { + _ = mgr.eventBus.Publish(queuedEvent) } - return newID, finalFilename, nil + mgr.pool.Add(*cfg) + + return cfg.ID, finalFilename, nil } return "", "", fmt.Errorf("failed to reserve unique working file for %q after %d attempts", req.URL, maxWorkingFileReservationAttempts) @@ -375,3 +363,65 @@ func (mgr *LifecycleManager) enqueueResolved(ctx context.Context, req *DownloadR func (mgr *LifecycleManager) IsNameActive(dir, name string) bool { return mgr.buildIsNameActive()(dir, name) } + +func (mgr *LifecycleManager) buildDownloadRecord(req *DownloadRequest, requestID string, finalPath string, finalFilename string, probeResult *probing.ProbeResult) (*types.DownloadRecord, error) { + if mgr.pool == nil { + return nil, types.ErrPoolNotInit + } + + settings := mgr.GetSettings() + id := strings.TrimSpace(requestID) + if id == "" { + id = uuid.New().String() + } + + if st := mgr.pool.GetStatus(id); st != nil { + return nil, types.ErrIDExists + } + + state := progress.New(id, 0) + state.SetDestPath(filepath.Join(finalPath, finalFilename)) + + runtime := settings.ToRuntimeConfig() + if req.Workers > 0 { + maxConns := runtime.GetMaxConnectionsPerDownload() + if req.Workers > maxConns { + req.Workers = maxConns + } + runtime.Workers = req.Workers + } + if req.MinChunkSize > 0 { + runtime.MinChunkSize = req.MinChunkSize + } + + var rateLimit int64 + var rateLimitSet bool + if settings.Network.DefaultDownloadRateLimit != nil { + if parsed, err := utils.ParseRateLimitValue(settings.Network.DefaultDownloadRateLimit.Value); err == nil { + rateLimit = parsed + rateLimitSet = true + } + } + + cfg := types.DownloadRecord{ + URL: req.URL, + Mirrors: req.Mirrors, + OutputPath: finalPath, + ID: id, + Filename: finalFilename, + ProgressState: state, + Runtime: runtime, + Headers: req.Headers, + IsExplicitCategory: req.IsExplicitCategory, + TotalSize: probeResult.FileSize, + SupportsRange: probeResult.SupportsRange, + RateLimit: rateLimit, + RateLimitSet: rateLimitSet, + } + + if mgr.eventBus != nil { + cfg.ProgressCh = mgr.eventBus.InputCh + } + + return &cfg, nil +} diff --git a/internal/orchestrator/manager_test.go b/internal/orchestrator/manager_test.go new file mode 100644 index 000000000..c2842dab3 --- /dev/null +++ b/internal/orchestrator/manager_test.go @@ -0,0 +1,232 @@ +package orchestrator + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/SurgeDM/Surge/internal/config" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" +) + +func TestLifecycleManager_Settings(t *testing.T) { + mgr := NewLifecycleManager(nil, nil) + defer mgr.Shutdown() + + s := mgr.GetSettings() + if s == nil { + t.Fatal("expected default settings, got nil") + } + + newSettings := config.DefaultSettings() + newSettings.Network.MaxConcurrentProbes.Value = 10 + mgr.ApplySettings(newSettings) + + s2 := mgr.GetSettings() + if s2.Network.MaxConcurrentProbes.Value != 10 { + t.Errorf("expected MaxConcurrentProbes to be 10, got %v", s2.Network.MaxConcurrentProbes.Value) + } +} + +func TestLifecycleManager_EnqueueSuccess(t *testing.T) { + // Create a test HTTP server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1024") + w.Header().Set("Accept-Ranges", "bytes") + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + progressCh := make(chan types.DownloadEvent, 10) + pool := scheduler.New(progressCh, 1) + eb := NewEventBus() + mgr := NewLifecycleManager(pool, eb) + defer mgr.Shutdown() + + destDir := t.TempDir() + + req := &DownloadRequest{ + URL: ts.URL + "/testfile.txt", + Filename: "testfile.txt", + Path: destDir, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + id, finalName, err := mgr.Enqueue(ctx, req) + if err != nil { + t.Fatalf("Enqueue failed: %v", err) + } + + if id == "" { + t.Error("expected non-empty ID") + } + if finalName != "testfile.txt" { + t.Errorf("expected testfile.txt, got %s", finalName) + } + + // Verify working file was created + surgePath := filepath.Join(destDir, finalName) + types.IncompleteSuffix + if _, err := os.Stat(surgePath); os.IsNotExist(err) { + t.Errorf("expected working file to be created at %s", surgePath) + } + + // Verify DownloadQueuedMsg was published + sub, cleanup := eb.Subscribe() + defer cleanup() + + // Wait a moment for async event to be broadcasted if any, though Enqueue synchronously calls eb.Publish + // We need to check if the event reached the subscriber. + found := false + timeout := time.After(500 * time.Millisecond) + for !found { + select { + case <-sub: + found = true + case <-timeout: + t.Fatal("timed out waiting for DownloadQueuedMsg") + } + } +} + +func TestLifecycleManager_EnqueueWithID(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + progressCh := make(chan types.DownloadEvent, 10) + pool := scheduler.New(progressCh, 1) + eb := NewEventBus() + mgr := NewLifecycleManager(pool, eb) + defer mgr.Shutdown() + + destDir := t.TempDir() + + req := &DownloadRequest{ + URL: ts.URL + "/test.zip", + Filename: "test.zip", + Path: destDir, + } + + customID := "my-custom-uuid-1234" + id, _, err := mgr.EnqueueWithID(context.Background(), req, customID) + if err != nil { + t.Fatalf("EnqueueWithID failed: %v", err) + } + + if id != customID { + t.Errorf("expected custom ID %s, got %s", customID, id) + } +} + +func TestLifecycleManager_IsNameActive(t *testing.T) { + activeFunc := func(dir, name string) bool { + return name == "active.txt" + } + + mgr := NewLifecycleManager(nil, nil, activeFunc) + + if !mgr.IsNameActive("/tmp", "active.txt") { + t.Error("expected true for active.txt") + } + if mgr.IsNameActive("/tmp", "other.txt") { + t.Error("expected false for other.txt") + } +} + +func TestLifecycleManager_EnqueueInvalid(t *testing.T) { + mgr := NewLifecycleManager(nil, nil) + + // Missing Pool + _, _, err := mgr.Enqueue(context.Background(), &DownloadRequest{URL: "http://example.com", Path: "/tmp"}) + if !errors.Is(err, types.ErrServiceUnavailable) { + t.Errorf("expected ErrServiceUnavailable, got %v", err) + } + + pool := scheduler.New(make(chan types.DownloadEvent, 1), 1) + mgr = NewLifecycleManager(pool, nil) + + // Missing URL + _, _, err = mgr.Enqueue(context.Background(), &DownloadRequest{Path: "/tmp"}) + if !errors.Is(err, types.ErrURLRequired) { + t.Errorf("expected ErrURLRequired, got %v", err) + } + + // Missing Path + _, _, err = mgr.Enqueue(context.Background(), &DownloadRequest{URL: "http://example.com"}) + if !errors.Is(err, types.ErrDestRequired) { + t.Errorf("expected ErrDestRequired, got %v", err) + } +} + +func TestLifecycleManager_ResumeBatch_CorruptStateIgnored(t *testing.T) { + // Setup store + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "surge.db") + store.Configure(dbPath) + t.Cleanup(func() { store.CloseDB() }) + + // Add two entries to master list + entry1 := types.DownloadRecord{ + ID: "id-valid", + URL: "http://example.com/valid", + DestPath: filepath.Join(tmpDir, "valid"), + Status: "paused", + } + entry2 := types.DownloadRecord{ + ID: "id-corrupt", + URL: "http://example.com/corrupt", + DestPath: filepath.Join(tmpDir, "corrupt"), + Status: "paused", + } + if err := store.AddToMasterList(entry1); err != nil { + t.Fatalf("failed to add entry1: %v", err) + } + if err := store.AddToMasterList(entry2); err != nil { + t.Fatalf("failed to add entry2: %v", err) + } + + // Write a corrupt gob state for entry2 + corruptStateFile := filepath.Join(tmpDir, "details", "id-corrupt.gob") + if err := os.MkdirAll(filepath.Dir(corruptStateFile), 0755); err != nil { + t.Fatalf("failed to create details dir: %v", err) + } + if err := os.WriteFile(corruptStateFile, []byte("not-a-gob"), 0644); err != nil { + t.Fatalf("failed to write corrupt state file: %v", err) + } + + // Initialize manager + progressCh := make(chan types.DownloadEvent, 10) + pool := scheduler.New(progressCh, 2) + eb := NewEventBus() + mgr := NewLifecycleManager(pool, eb) + defer mgr.Shutdown() + + errs := mgr.ResumeBatch([]string{"id-valid", "id-corrupt"}) + + if len(errs) != 2 { + t.Fatalf("expected 2 errors, got %d", len(errs)) + } + if errs[0] != nil { + t.Errorf("expected no error for id-valid, got %v", errs[0]) + } + if errs[1] != nil { + // Even for the corrupt one, it should fallback to the master list and successfully enqueue a fresh resume + t.Errorf("expected no error for id-corrupt, got %v", errs[1]) + } + + // Check that pool has both downloads enqueued/started + if pool.GetStatus("id-valid") == nil { + t.Errorf("expected id-valid to be in pool") + } + if pool.GetStatus("id-corrupt") == nil { + t.Errorf("expected id-corrupt to be in pool") + } +} diff --git a/internal/processing/pause_resume.go b/internal/orchestrator/pause_resume.go similarity index 57% rename from internal/processing/pause_resume.go rename to internal/orchestrator/pause_resume.go index 324a8bab3..c9a1748d5 100644 --- a/internal/processing/pause_resume.go +++ b/internal/orchestrator/pause_resume.go @@ -1,55 +1,34 @@ -package processing +package orchestrator import ( - "fmt" "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) -// EngineHooks defines the minimal callbacks Processing needs to orchestrate the worker pool. -// All management decisions (event emission, DB persistence, state loading) live here in -// LifecycleManager; the pool itself is a pure executor. -type EngineHooks struct { - // Pause signals the pool to mechanically pause a download (cancel context, set state). - Pause func(id string) bool - // ExtractPausedConfig atomically removes a paused download from the pool and returns - // its config so LifecycleManager can re-enqueue it after hydration from saved state. - // Returns nil when not found / not paused / still transitioning. - ExtractPausedConfig func(id string) *types.DownloadConfig - // GetStatus returns the in-memory status for a download. - GetStatus func(id string) *types.DownloadStatus - // AddConfig enqueues a DownloadConfig. The pool sets cfg.ProgressCh when nil. - AddConfig func(cfg types.DownloadConfig) - // Cancel mechanically removes a download from the pool and returns removal metadata. - Cancel func(id string) types.CancelResult - // UpdateURL updates the in-memory URL only; LifecycleManager persists to DB. - UpdateURL func(id, newURL string) error - // PublishEvent sends an event into the service's broadcast channel. - PublishEvent func(msg interface{}) error -} +// removed EngineHooks // Pause pauses an active download. func (mgr *LifecycleManager) Pause(id string) error { - hooks := mgr.getEngineHooks() - if hooks.Pause == nil { + if mgr.pool == nil { return types.ErrEngineNotInit } - if hooks.Pause(id) { + if mgr.pool.Pause(id) { return nil } // Downloads paused in a prior session are not tracked by the in-memory pool; // synthesize a paused event so the UI can clear any transient "pausing" spinner. - entry, err := state.GetDownload(id) + entry, err := store.GetDownload(id) if err == nil && entry != nil { - if hooks.PublishEvent != nil { - _ = hooks.PublishEvent(events.DownloadPausedMsg{ + if mgr.eventBus != nil { + _ = mgr.eventBus.Publish(types.DownloadEvent{ + Type: types.EventPaused, DownloadID: id, Filename: entry.Filename, Downloaded: entry.Downloaded, @@ -66,20 +45,22 @@ func (mgr *LifecycleManager) Pause(id string) error { // hydrateConfigFromDisk loads the latest persisted pause snapshot from disk // and merges it into cfg so the download resumes at the correct byte offset // and task list even when the pool's in-memory state is stale. -func hydrateConfigFromDisk(cfg *types.DownloadConfig) { +func hydrateConfigFromDisk(cfg *types.DownloadRecord) { if cfg.URL == "" || cfg.DestPath == "" { return } - saved, err := state.LoadState(cfg.URL, cfg.DestPath) + saved, err := store.LoadState(cfg.URL, cfg.DestPath) if err != nil || saved == nil { return } - cfg.SavedState = saved if saved.TotalSize > 0 { cfg.TotalSize = saved.TotalSize } if len(saved.Tasks) > 0 { cfg.SupportsRange = true + cfg.Tasks = saved.Tasks + cfg.ChunkBitmap = saved.ChunkBitmap + cfg.ActualChunkSize = saved.ActualChunkSize } } @@ -88,35 +69,43 @@ func hydrateConfigFromDisk(cfg *types.DownloadConfig) { // Hot path: download is still in pool memory (same session) - extract config directly. // Cold path: download was paused in a prior session, only stored in DB. func (mgr *LifecycleManager) Resume(id string) error { - hooks := mgr.getEngineHooks() + if mgr.pool == nil { + return types.ErrEngineNotInit + } // Guard: still transitioning to paused - if hooks.GetStatus != nil { - if st := hooks.GetStatus(id); st != nil && st.Status == "pausing" { + if st := mgr.pool.GetStatus(id); st != nil { + switch st.Status { + case "pausing": return types.ErrPausing + case "downloading", "queued": + return types.ErrAlreadyActive } } // Hot path: pool still holds the paused download in memory. - if hooks.ExtractPausedConfig != nil { - if cfg := hooks.ExtractPausedConfig(id); cfg != nil { - hydrateConfigFromDisk(cfg) - cfg.IsResume = true - if hooks.AddConfig != nil { - hooks.AddConfig(*cfg) - } - if hooks.PublishEvent != nil { - _ = hooks.PublishEvent(events.DownloadResumedMsg{ - DownloadID: id, - Filename: cfg.Filename, - }) - } - return nil + if cfg := mgr.pool.ExtractPausedConfig(id); cfg != nil { + hydrateConfigFromDisk(cfg) + cfg.IsResume = true + + if mgr.eventBus != nil { + cfg.ProgressCh = mgr.eventBus.InputCh } + + mgr.pool.Add(*cfg) + + if mgr.eventBus != nil { + _ = mgr.eventBus.Publish(types.DownloadEvent{ + Type: types.EventResumed, + DownloadID: id, + Filename: cfg.Filename, + }) + } + return nil } // Cold path: download from a prior session (only in DB). - entry, err := state.GetDownload(id) + entry, err := store.GetDownload(id) if err != nil || entry == nil { return types.ErrNotFound } @@ -132,18 +121,21 @@ func (mgr *LifecycleManager) Resume(id string) error { outputPath = "." } - savedState, stateErr := state.LoadState(entry.URL, entry.DestPath) + savedState, stateErr := store.LoadState(entry.URL, entry.DestPath) if stateErr != nil { savedState = nil } cfg := buildResumeConfig(id, outputPath, entry, savedState, settings) - if hooks.AddConfig != nil { - hooks.AddConfig(cfg) + if mgr.eventBus != nil { + cfg.ProgressCh = mgr.eventBus.InputCh } - if hooks.PublishEvent != nil { - _ = hooks.PublishEvent(events.DownloadResumedMsg{ + mgr.pool.Add(cfg) + + if mgr.eventBus != nil { + _ = mgr.eventBus.Publish(types.DownloadEvent{ + Type: types.EventResumed, DownloadID: id, Filename: entry.Filename, }) @@ -155,7 +147,12 @@ func (mgr *LifecycleManager) Resume(id string) error { func (mgr *LifecycleManager) ResumeBatch(ids []string) []error { errs := make([]error, len(ids)) - hooks := mgr.getEngineHooks() + if mgr.pool == nil { + for i := range errs { + errs[i] = types.ErrEngineNotInit + } + return errs + } settings := mgr.GetSettings() outputPath := config.Resolve[string](settings.General.DefaultDownloadDir) @@ -168,30 +165,37 @@ func (mgr *LifecycleManager) ResumeBatch(ids []string) []error { coldIdx := make(map[string]int) for i, id := range ids { - if hooks.GetStatus != nil { - if st := hooks.GetStatus(id); st != nil && st.Status == "pausing" { + if st := mgr.pool.GetStatus(id); st != nil { + switch st.Status { + case "pausing": errs[i] = types.ErrPausing continue + case "downloading", "queued": + errs[i] = types.ErrAlreadyActive + continue } } // Try hot path first - if hooks.ExtractPausedConfig != nil { - if cfg := hooks.ExtractPausedConfig(id); cfg != nil { - hydrateConfigFromDisk(cfg) - cfg.IsResume = true - if hooks.AddConfig != nil { - hooks.AddConfig(*cfg) - } - if hooks.PublishEvent != nil { - _ = hooks.PublishEvent(events.DownloadResumedMsg{ - DownloadID: id, - Filename: cfg.Filename, - }) - } - errs[i] = nil - continue + if cfg := mgr.pool.ExtractPausedConfig(id); cfg != nil { + hydrateConfigFromDisk(cfg) + cfg.IsResume = true + + if mgr.eventBus != nil { + cfg.ProgressCh = mgr.eventBus.InputCh + } + + mgr.pool.Add(*cfg) + + if mgr.eventBus != nil { + _ = mgr.eventBus.Publish(types.DownloadEvent{ + Type: types.EventResumed, + DownloadID: id, + Filename: cfg.Filename, + }) } + errs[i] = nil + continue } // Tag for cold-path batch load @@ -203,31 +207,50 @@ func (mgr *LifecycleManager) ResumeBatch(ids []string) []error { return errs } - states, err := state.LoadStates(coldIDs) - if err != nil { - for _, id := range coldIDs { - idx := coldIdx[id] - errs[idx] = fmt.Errorf("failed to load state: %w", err) + states, _ := store.LoadStates(coldIDs) + + masterList, mErr := store.LoadMasterList() + var masterMap map[string]*types.DownloadRecord + if mErr == nil && masterList != nil { + masterMap = make(map[string]*types.DownloadRecord, len(masterList.Downloads)) + for i := range masterList.Downloads { + e := &masterList.Downloads[i] + masterMap[e.ID] = e } - return errs } for _, id := range coldIDs { idx := coldIdx[id] - savedState, ok := states[id] - if !ok { - errs[idx] = fmt.Errorf("download not found or completed") + savedState := states[id] + + var entry *types.DownloadRecord + if masterMap != nil { + entry = masterMap[id] + } + + if savedState == nil && entry == nil { + errs[idx] = types.ErrNotFound + continue + } + + if entry != nil && entry.Status == "completed" { + errs[idx] = types.ErrCompleted continue } - cfg := buildResumeConfig(id, outputPath, nil, savedState, settings) - if hooks.AddConfig != nil { - hooks.AddConfig(cfg) + cfg := buildResumeConfig(id, outputPath, entry, savedState, settings) + + if mgr.eventBus != nil { + cfg.ProgressCh = mgr.eventBus.InputCh } - if hooks.PublishEvent != nil { - _ = hooks.PublishEvent(events.DownloadResumedMsg{ + + mgr.pool.Add(cfg) + + if mgr.eventBus != nil { + _ = mgr.eventBus.Publish(types.DownloadEvent{ + Type: types.EventResumed, DownloadID: id, - Filename: savedState.Filename, + Filename: cfg.Filename, }) } errs[idx] = nil @@ -239,15 +262,13 @@ func (mgr *LifecycleManager) ResumeBatch(ids []string) []error { // Cancel stops a download (both pool in-memory and DB) and emits a removal event. // The event worker handles file cleanup and DB removal via DownloadRemovedMsg. func (mgr *LifecycleManager) Cancel(id string) error { - hooks := mgr.getEngineHooks() - var filename, destPath string var completed bool var found bool // Mechanical cancel via pool - if hooks.Cancel != nil { - result := hooks.Cancel(id) + if mgr.pool != nil { + result := mgr.pool.Cancel(id) if result.Found { found = true filename = result.Filename @@ -257,7 +278,7 @@ func (mgr *LifecycleManager) Cancel(id string) error { } // Supplement with DB info (covers DB-only / completed entries) - if entry, err := state.GetDownload(id); err == nil && entry != nil { + if entry, err := store.GetDownload(id); err == nil && entry != nil { found = true if filename == "" { filename = entry.Filename @@ -279,8 +300,9 @@ func (mgr *LifecycleManager) Cancel(id string) error { } // Emit removal event - event worker handles DB deletion and file cleanup. - if hooks.PublishEvent != nil { - _ = hooks.PublishEvent(events.DownloadRemovedMsg{ + if mgr.eventBus != nil { + _ = mgr.eventBus.Publish(types.DownloadEvent{ + Type: types.EventRemoved, DownloadID: id, Filename: filename, DestPath: destPath, @@ -292,25 +314,23 @@ func (mgr *LifecycleManager) Cancel(id string) error { // UpdateURL updates the URL of a download in both the pool (in-memory) and the DB. func (mgr *LifecycleManager) UpdateURL(id string, newURL string) error { - hooks := mgr.getEngineHooks() - // Update in-memory state via pool (validates download state too) - if hooks.UpdateURL != nil { - if err := hooks.UpdateURL(id, newURL); err != nil { + if mgr.pool != nil { + if err := mgr.pool.UpdateURL(id, newURL); err != nil { return err } // Pool update succeeded; persist to DB. - return state.UpdateURL(id, newURL) + return store.UpdateURL(id, newURL) } // No pool connected - DB-only update is correct (no in-memory state to sync). - return state.UpdateURL(id, newURL) + return store.UpdateURL(id, newURL) } -// buildResumeConfig constructs a DownloadConfig for a cold-path resume from saved state. +// buildResumeConfig constructs a DownloadRecord for a cold-path resume from saved state. // When entry is non-nil it provides identity fields (URL, filename, destPath); savedState // takes precedence for progress, elapsed time, and mirror topology. If savedState is nil, // SupportsRange is false and the download restarts from the entry's Downloaded offset. -func buildResumeConfig(id, outputPath string, entry *types.DownloadEntry, savedState *types.DownloadState, settings *config.Settings) types.DownloadConfig { +func buildResumeConfig(id, outputPath string, entry *types.DownloadRecord, savedState *types.DownloadRecord, settings *config.Settings) types.DownloadRecord { var destPath, url, filename string var totalSize, downloaded int64 var rateLimit int64 @@ -351,12 +371,12 @@ func buildResumeConfig(id, outputPath string, entry *types.DownloadEntry, savedS } var mirrorURLs []string - var dmState *types.ProgressState + var dmState *progress.DownloadProgress if savedState != nil { - dmState = types.NewProgressState(id, savedState.TotalSize) - dmState.Downloaded.Store(savedState.Downloaded) - dmState.VerifiedProgress.Store(savedState.Downloaded) + dmState = progress.New(id, savedState.TotalSize) + dmState.Bytes.Downloaded.Store(savedState.Downloaded) + dmState.Bytes.VerifiedProgress.Store(savedState.Downloaded) if savedState.Elapsed > 0 { dmState.SetSavedElapsed(time.Duration(savedState.Elapsed)) } @@ -368,32 +388,43 @@ func buildResumeConfig(id, outputPath string, entry *types.DownloadEntry, savedS } dmState.SetMirrors(mirrors) } - dmState.DestPath = destPath + dmState.SetDestPath(destPath) dmState.SyncSessionStart() } else { - dmState = types.NewProgressState(id, totalSize) - dmState.Downloaded.Store(downloaded) - dmState.VerifiedProgress.Store(downloaded) - dmState.DestPath = destPath + dmState = progress.New(id, totalSize) + dmState.Bytes.Downloaded.Store(downloaded) + dmState.Bytes.VerifiedProgress.Store(downloaded) + dmState.SetDestPath(destPath) dmState.SyncSessionStart() mirrorURLs = []string{url} } dmState.SetRateLimit(rateLimit, rateLimitSet) - return types.DownloadConfig{ - URL: url, - OutputPath: outputPath, - DestPath: destPath, - ID: id, - Filename: filename, - TotalSize: totalSize, - SupportsRange: savedState != nil && len(savedState.Tasks) > 0, - IsResume: true, - State: dmState, - SavedState: savedState, - Runtime: runtime, - Mirrors: mirrorURLs, - RateLimitBps: rateLimit, - RateLimitSet: rateLimitSet, + var tasks []types.Task + var chunkBitmap []byte + var actualChunkSize int64 + if savedState != nil { + tasks = savedState.Tasks + chunkBitmap = savedState.ChunkBitmap + actualChunkSize = savedState.ActualChunkSize + } + + return types.DownloadRecord{ + URL: url, + OutputPath: outputPath, + DestPath: destPath, + ID: id, + Filename: filename, + TotalSize: totalSize, + SupportsRange: savedState != nil && len(savedState.Tasks) > 0, + IsResume: true, + ProgressState: dmState, + Runtime: runtime, + Mirrors: mirrorURLs, + RateLimit: rateLimit, + RateLimitSet: rateLimitSet, + Tasks: tasks, + ChunkBitmap: chunkBitmap, + ActualChunkSize: actualChunkSize, } } diff --git a/internal/orchestrator/progress.go b/internal/orchestrator/progress.go new file mode 100644 index 000000000..574236473 --- /dev/null +++ b/internal/orchestrator/progress.go @@ -0,0 +1,146 @@ +package orchestrator + +import ( + "context" + "sync" + "time" + + "github.com/SurgeDM/Surge/internal/config" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/types" +) + +const ( + SpeedSmoothingAlpha = 0.3 + ReportInterval = 150 * time.Millisecond +) + +type ProgressAggregator struct { + pool *scheduler.Scheduler + eventBus *EventBus + settingsMu sync.RWMutex + settings *config.Settings + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +func NewProgressAggregator(pool *scheduler.Scheduler, eventBus *EventBus, settings *config.Settings) *ProgressAggregator { + ctx, cancel := context.WithCancel(context.Background()) + pa := &ProgressAggregator{ + pool: pool, + eventBus: eventBus, + settings: settings, + ctx: ctx, + cancel: cancel, + } + pa.wg.Add(1) + go pa.reportProgressLoop() + return pa +} + +func (pa *ProgressAggregator) SetSettings(settings *config.Settings) { + pa.settingsMu.Lock() + pa.settings = settings + pa.settingsMu.Unlock() +} + +func (pa *ProgressAggregator) getSpeedEmaAlpha() float64 { + pa.settingsMu.RLock() + settings := pa.settings + pa.settingsMu.RUnlock() + + if settings == nil { + return SpeedSmoothingAlpha + } + + alpha := config.Resolve[float64](settings.Performance.SpeedEmaAlpha) + if alpha <= 0 || alpha > 1 { + return SpeedSmoothingAlpha + } + + return alpha +} + +func (pa *ProgressAggregator) reportProgressLoop() { + defer pa.wg.Done() + lastSpeeds := make(map[string]float64) + lastChunkSnapshot := make(map[string]time.Time) + ticker := time.NewTicker(ReportInterval) + defer ticker.Stop() + + for { + select { + case <-pa.ctx.Done(): + return + case <-ticker.C: + } + + if pa.pool == nil { + continue + } + alpha := pa.getSpeedEmaAlpha() + + var batch []types.DownloadEvent + activeConfigs := pa.pool.GetAll() + + for _, cfg := range activeConfigs { + if cfg.ProgressState == nil || progress.CfgProgress(&cfg).IsPaused() || progress.CfgProgress(&cfg).Done.Load() { + delete(lastSpeeds, cfg.ID) + delete(lastChunkSnapshot, cfg.ID) + continue + } + + downloaded, total, totalElapsed, sessionElapsed, connections, sessionStart := progress.CfgProgress(&cfg).GetProgress() + sessionDownloaded := downloaded - sessionStart + + var instantSpeed float64 + if sessionElapsed.Seconds() > 0 && sessionDownloaded > 0 { + instantSpeed = float64(sessionDownloaded) / sessionElapsed.Seconds() + } + + lastSpeed := lastSpeeds[cfg.ID] + var currentSpeed float64 + if lastSpeed == 0 { + currentSpeed = instantSpeed + } else { + currentSpeed = alpha*instantSpeed + (1-alpha)*lastSpeed + } + lastSpeeds[cfg.ID] = currentSpeed + + msg := types.DownloadEvent{ + Type: types.EventProgress, + DownloadID: cfg.ID, + Downloaded: downloaded, + Total: total, + Speed: currentSpeed, + Elapsed: totalElapsed, + Connections: int(connections), + RateLimited: progress.CfgProgress(&cfg).RateLimited.Load(), + } + + if time.Since(lastChunkSnapshot[cfg.ID]) >= 500*time.Millisecond { + bitmap, width, _, chunkSize, chunkProgress := progress.CfgProgress(&cfg).GetBitmapSnapshot(true) + if width > 0 && len(bitmap) > 0 { + msg.ChunkBitmap = bitmap + msg.BitmapWidth = width + msg.ChunkSize = chunkSize + msg.ChunkProgress = chunkProgress + lastChunkSnapshot[cfg.ID] = time.Now() + } + } + + batch = append(batch, msg) + } + + if len(batch) > 0 { + _ = pa.eventBus.Publish(types.DownloadEvent{Type: types.EventBatchProgress, BatchEvents: batch}) + } + } +} + +func (pa *ProgressAggregator) Shutdown() { + pa.cancel() + pa.wg.Wait() +} diff --git a/internal/orchestrator/progress_test.go b/internal/orchestrator/progress_test.go new file mode 100644 index 000000000..127186a33 --- /dev/null +++ b/internal/orchestrator/progress_test.go @@ -0,0 +1,94 @@ +package orchestrator + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/SurgeDM/Surge/internal/config" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/types" +) + +func TestProgressAggregator_Loop(t *testing.T) { + // 1. Setup a test server that blocks so the download stays active + blockCh := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1024") + w.WriteHeader(http.StatusOK) + <-blockCh // block the download + })) + defer ts.Close() + defer close(blockCh) + + // 2. Setup Pool and EventBus + progressCh := make(chan types.DownloadEvent, 10) + pool := scheduler.New(progressCh, 1) + eb := NewEventBus() + defer eb.Shutdown() + + // 3. Start Aggregator + settings := config.DefaultSettings() + agg := NewProgressAggregator(pool, eb, settings) + defer agg.Shutdown() + + // 4. Start Download + state := progress.New("agg-test", 1024) + tmpDir := t.TempDir() + cfg := types.DownloadRecord{ + ID: "agg-test", + URL: ts.URL, + OutputPath: tmpDir, + Filename: "test.txt", + ProgressState: state, + TotalSize: 1024, + Runtime: types.DefaultRuntimeConfig(), + } + pool.Add(cfg) + + // Update state manually to simulate progress + state.Bytes.Downloaded.Store(512) + state.Bytes.VerifiedProgress.Store(512) + + // 5. Subscribe and check for BatchProgressMsg + sub, cleanup := eb.Subscribe() + defer cleanup() + + timeout := time.After(2 * time.Second) + for { + select { + case msg := <-sub: + if msg.Type == types.EventBatchProgress { + if len(msg.BatchEvents) > 0 { + pMsg := msg.BatchEvents[0] + if pMsg.DownloadID == "agg-test" { + if pMsg.Downloaded == 512 { + return // Success! + } + } + } + } + case <-timeout: + t.Fatal("timed out waiting for BatchProgressMsg with 512 bytes downloaded") + } + } +} + +func TestProgressAggregator_Settings(t *testing.T) { + agg := NewProgressAggregator(nil, nil, nil) + defer agg.Shutdown() + + if agg.getSpeedEmaAlpha() != SpeedSmoothingAlpha { + t.Errorf("expected default alpha, got %v", agg.getSpeedEmaAlpha()) + } + + settings := config.DefaultSettings() + settings.Performance.SpeedEmaAlpha.Value = 0.5 + agg.SetSettings(settings) + + if agg.getSpeedEmaAlpha() != 0.5 { + t.Errorf("expected 0.5 alpha, got %v", agg.getSpeedEmaAlpha()) + } +} diff --git a/internal/probe/probe.go b/internal/probe/probe.go index 16e2da5c5..523c1f49c 100644 --- a/internal/probe/probe.go +++ b/internal/probe/probe.go @@ -13,8 +13,8 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -91,11 +91,11 @@ func ProbeServerWithProxy(ctx context.Context, rawurl string, filenameHint strin } // Standardize on PoolMaxConnsPerHost for probes to match the eventual download path - transport := engine.DefaultNetworkPool.AcquireTransport(proxyURL, customDNS, types.PoolMaxConnsPerHost) - defer engine.DefaultNetworkPool.ReleaseTransport(transport) + transport_ := transport.DefaultNetworkPool.AcquireTransport(proxyURL, customDNS, types.PoolMaxConnsPerHost) + defer transport.DefaultNetworkPool.ReleaseTransport(transport_) client := &http.Client{ - Transport: transport, + Transport: transport_, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("stopped after 10 redirects") diff --git a/internal/processing/events_internal_test.go b/internal/processing/events_internal_test.go deleted file mode 100644 index a87f45cfc..000000000 --- a/internal/processing/events_internal_test.go +++ /dev/null @@ -1,344 +0,0 @@ -package processing - -import ( - "errors" - "os" - "path/filepath" - "syscall" - "testing" - "time" - - "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/testutil" - "github.com/SurgeDM/Surge/internal/utils" -) - -func TestFinalizeCompletedFile_CopiesAcrossDevicesOnEXDEV(t *testing.T) { - tempDir := t.TempDir() - finalPath := filepath.Join(tempDir, "video.mp4") - surgePath := finalPath + types.IncompleteSuffix - if err := os.WriteFile(surgePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create working file: %v", err) - } - - origRename := renameCompletedFile - origCopy := copyCompletedFile - t.Cleanup(func() { - renameCompletedFile = origRename - copyCompletedFile = origCopy - }) - - var copied bool - renameCompletedFile = func(string, string) error { - return &os.LinkError{Op: "rename", Old: surgePath, New: finalPath, Err: syscall.EXDEV} - } - copyCompletedFile = func(src, dst string) error { - copied = true - data, err := os.ReadFile(src) - if err != nil { - return err - } - return os.WriteFile(dst, data, 0o644) - } - - if err := finalizeCompletedFile(finalPath); err != nil { - t.Fatalf("finalizeCompletedFile failed: %v", err) - } - if !copied { - t.Fatal("expected copy fallback to run on EXDEV") - } - - data, err := os.ReadFile(finalPath) - if err != nil { - t.Fatalf("failed to read finalized file: %v", err) - } - if string(data) != "partial" { - t.Fatalf("final data = %q, want partial", string(data)) - } - if _, err := os.Stat(surgePath); !os.IsNotExist(err) { - t.Fatalf("expected working file removal after copy fallback, stat err: %v", err) - } -} - -func TestStartEventWorker_MarksCompletionAsErrorWhenFinalizationFails(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - - finalPath := filepath.Join(tempDir, "video.mp4") - surgePath := finalPath + types.IncompleteSuffix - if err := os.WriteFile(surgePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create working file: %v", err) - } - - if err := state.AddToMasterList(types.DownloadEntry{ - ID: "download-1", - URL: "https://example.com/video.mp4", - URLHash: state.URLHash("https://example.com/video.mp4"), - DestPath: finalPath, - Filename: "video.mp4", - Status: "downloading", - Workers: 8, - MinChunkSize: 4 * utils.MiB, - }); err != nil { - t.Fatalf("failed to seed download entry: %v", err) - } - - origRename := renameCompletedFile - origNotify := notify - t.Cleanup(func() { - renameCompletedFile = origRename - notify = origNotify - }) - renameCompletedFile = func(string, string) error { - return errors.New("disk full") - } - var calls []struct { - title string - msg string - } - notify = func(title, msg string) { - calls = append(calls, struct { - title string - msg string - }{title: title, msg: msg}) - } - - settingsDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", settingsDir) - settings := config.DefaultSettings() - settings.General.DownloadCompleteNotification.Value = true - if err := config.SaveSettings(settings); err != nil { - t.Fatalf("failed to save settings: %v", err) - } - - mgr := NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 1) - ch <- events.DownloadCompleteMsg{ - DownloadID: "download-1", - Filename: "video.mp4", - Elapsed: 2 * time.Second, - Total: 7, - } - close(ch) - - mgr.StartEventWorker(ch) - - entry, err := state.GetDownload("download-1") - if err != nil { - t.Fatalf("failed to reload entry: %v", err) - } - if entry == nil { - t.Fatal("expected download entry to remain") - return - } - if entry.Status != "error" { - t.Fatalf("status = %q, want error", entry.Status) - } - if _, err := os.Stat(surgePath); err != nil { - t.Fatalf("expected working file to remain for retry, stat err: %v", err) - } - if _, err := os.Stat(finalPath); !os.IsNotExist(err) { - t.Fatalf("expected no finalized file after failure, stat err: %v", err) - } - if len(calls) != 1 { - t.Fatalf("notification calls = %d, want 1", len(calls)) - } - if calls[0].title != "Download failed: video.mp4" { - t.Fatalf("notification title = %q, want %q", calls[0].title, "Download failed: video.mp4") - } - if calls[0].msg != "disk full" { - t.Fatalf("notification msg = %q, want %q", calls[0].msg, "disk full") - } - if entry.Workers != 8 { - t.Fatalf("Workers = %d, want 8 (preserved from existing entry)", entry.Workers) - } - if entry.MinChunkSize != 4*utils.MiB { - t.Fatalf("MinChunkSize = %d, want %d (preserved from existing entry)", entry.MinChunkSize, 4*utils.MiB) - } -} - -func TestStartEventWorker_RemovesIncompleteFileOnErrorWithoutDBEntry(t *testing.T) { - tempDir := t.TempDir() - destPath := filepath.Join(tempDir, "video.mp4") - surgePath := destPath + types.IncompleteSuffix - if err := os.WriteFile(surgePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create working file: %v", err) - } - - mgr := NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 1) - ch <- events.DownloadErrorMsg{ - DownloadID: "download-no-db", - Filename: "video.mp4", - DestPath: destPath, - Err: errors.New("boom"), - } - close(ch) - - mgr.StartEventWorker(ch) - - if _, err := os.Stat(surgePath); !os.IsNotExist(err) { - t.Fatalf("expected working file to be removed even without DB entry, stat err: %v", err) - } -} - -func TestStartEventWorker_SuppressesNotificationWhenSettingDisabled(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - - finalPath := filepath.Join(tempDir, "video.mp4") - surgePath := finalPath + types.IncompleteSuffix - if err := os.WriteFile(surgePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create working file: %v", err) - } - - if err := state.AddToMasterList(types.DownloadEntry{ - ID: "download-1", - URL: "https://example.com/video.mp4", - URLHash: state.URLHash("https://example.com/video.mp4"), - DestPath: finalPath, - Filename: "video.mp4", - Status: "downloading", - }); err != nil { - t.Fatalf("failed to seed download entry: %v", err) - } - - settingsDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", settingsDir) - settings := config.DefaultSettings() - settings.General.DownloadCompleteNotification.Value = false - if err := config.SaveSettings(settings); err != nil { - t.Fatalf("failed to save settings: %v", err) - } - - origNotify := notify - t.Cleanup(func() { notify = origNotify }) - var calls int - notify = func(string, string) { calls++ } - - mgr := NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 1) - ch <- events.DownloadCompleteMsg{ - DownloadID: "download-1", - Filename: "video.mp4", - Elapsed: 1 * time.Second, - Total: 7, - } - close(ch) - - mgr.StartEventWorker(ch) - - if calls != 0 { - t.Fatalf("notification calls = %d, want 0", calls) - } -} - -func TestStartEventWorker_CompletionNotificationUsesGenericMessageWhenElapsedZero(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - - finalPath := filepath.Join(tempDir, "video.mp4") - surgePath := finalPath + types.IncompleteSuffix - if err := os.WriteFile(surgePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create working file: %v", err) - } - - if err := state.AddToMasterList(types.DownloadEntry{ - ID: "download-1", - URL: "https://example.com/video.mp4", - URLHash: state.URLHash("https://example.com/video.mp4"), - DestPath: finalPath, - Filename: "video.mp4", - Status: "downloading", - }); err != nil { - t.Fatalf("failed to seed download entry: %v", err) - } - - settingsDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", settingsDir) - settings := config.DefaultSettings() - settings.General.DownloadCompleteNotification.Value = true - if err := config.SaveSettings(settings); err != nil { - t.Fatalf("failed to save settings: %v", err) - } - - origNotify := notify - t.Cleanup(func() { notify = origNotify }) - var calls []struct { - title string - msg string - } - notify = func(title, msg string) { - calls = append(calls, struct { - title string - msg string - }{title: title, msg: msg}) - } - - mgr := NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 1) - ch <- events.DownloadCompleteMsg{ - DownloadID: "download-1", - Filename: "video.mp4", - Elapsed: 0, - Total: 7, - } - close(ch) - - mgr.StartEventWorker(ch) - - if len(calls) != 1 { - t.Fatalf("notification calls = %d, want 1", len(calls)) - } - if calls[0].title != "Download Complete: video.mp4" { - t.Fatalf("notification title = %q, want %q", calls[0].title, "Download Complete: video.mp4") - } - if calls[0].msg != "Download complete!" { - t.Fatalf("notification msg = %q, want %q", calls[0].msg, "Download complete!") - } -} - -func TestStartEventWorker_ErrorNotificationFallsBackToDownloadID(t *testing.T) { - settingsDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", settingsDir) - settings := config.DefaultSettings() - settings.General.DownloadCompleteNotification.Value = true - if err := config.SaveSettings(settings); err != nil { - t.Fatalf("failed to save settings: %v", err) - } - - origNotify := notify - t.Cleanup(func() { notify = origNotify }) - var calls []struct { - title string - msg string - } - notify = func(title, msg string) { - calls = append(calls, struct { - title string - msg string - }{title: title, msg: msg}) - } - - mgr := NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 1) - ch <- events.DownloadErrorMsg{ - DownloadID: "download-42", - Filename: "", - DestPath: "", - Err: errors.New("boom"), - } - close(ch) - - mgr.StartEventWorker(ch) - - if len(calls) != 1 { - t.Fatalf("notification calls = %d, want 1", len(calls)) - } - if calls[0].title != "Download failed: download-42" { - t.Fatalf("notification title = %q, want %q", calls[0].title, "Download failed: download-42") - } - if calls[0].msg != "boom" { - t.Fatalf("notification msg = %q, want %q", calls[0].msg, "boom") - } -} diff --git a/internal/processing/events_test.go b/internal/processing/events_test.go deleted file mode 100644 index 7a41c5b23..000000000 --- a/internal/processing/events_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package processing_test - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" - "github.com/SurgeDM/Surge/internal/testutil" - "github.com/SurgeDM/Surge/internal/utils" -) - -func TestStartEventWorker_FinalizesCompletedFileUsingDestPath(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - - finalPath := filepath.Join(tempDir, "video.mp4") - surgePath := finalPath + types.IncompleteSuffix - if err := os.WriteFile(surgePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create incomplete file: %v", err) - } - - if err := state.AddToMasterList(types.DownloadEntry{ - ID: "download-1", - URL: "https://example.com/video.mp4", - URLHash: state.URLHash("https://example.com/video.mp4"), - DestPath: finalPath, - Filename: "video.mp4", - Status: "downloading", - }); err != nil { - t.Fatalf("failed to seed download entry: %v", err) - } - if err := state.SaveStateWithOptions("https://example.com/video.mp4", finalPath, &types.DownloadState{ - ID: "download-1", - URL: "https://example.com/video.mp4", - Filename: "video.mp4", - DestPath: finalPath, - TotalSize: 7, - Tasks: []types.Task{ - {Offset: 0, Length: 7}, - }, - }, state.SaveStateOptions{SkipFileHash: true}); err != nil { - t.Fatalf("failed to seed download state: %v", err) - } - - mgr := processing.NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 1) - ch <- events.DownloadCompleteMsg{ - DownloadID: "download-1", - Filename: "video.mp4", - Elapsed: 2 * time.Second, - Total: 7, - } - close(ch) - - mgr.StartEventWorker(ch) - - if _, err := os.Stat(finalPath); err != nil { - t.Fatalf("expected finalized file at %s: %v", finalPath, err) - } - if _, err := os.Stat(surgePath); !os.IsNotExist(err) { - t.Fatalf("expected incomplete file to be removed, stat err: %v", err) - } - - entry, err := state.GetDownload("download-1") - if err != nil { - t.Fatalf("failed to reload completed entry: %v", err) - } - if entry == nil { - t.Fatal("expected completed entry to exist") - return - } - if entry.Status != "completed" { - t.Fatalf("status = %q, want completed", entry.Status) - } - if entry.DestPath != finalPath { - t.Fatalf("dest_path = %q, want %q", entry.DestPath, finalPath) - } - - loadedState, _ := state.LoadState("https://example.com/video.mp4", finalPath) - if loadedState != nil && len(loadedState.Tasks) != 0 { - t.Fatalf("task_count = %d, want 0", len(loadedState.Tasks)) - } -} - -func TestStartEventWorker_CompletionPreservesOverrideMetadata(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - - finalPath := filepath.Join(tempDir, "video.mp4") - surgePath := finalPath + types.IncompleteSuffix - if err := os.WriteFile(surgePath, []byte("partial"), 0o644); err != nil { - t.Fatalf("failed to create incomplete file: %v", err) - } - - if err := state.AddToMasterList(types.DownloadEntry{ - ID: "download-1", - URL: "https://example.com/video.mp4", - URLHash: state.URLHash("https://example.com/video.mp4"), - DestPath: finalPath, - Filename: "video.mp4", - Status: "downloading", - Workers: 8, - MinChunkSize: 4 * utils.MiB, - }); err != nil { - t.Fatalf("failed to seed download entry: %v", err) - } - - mgr := processing.NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 1) - ch <- events.DownloadCompleteMsg{ - DownloadID: "download-1", - Filename: "video.mp4", - Elapsed: 2 * time.Second, - Total: 7, - } - close(ch) - - mgr.StartEventWorker(ch) - - entry, err := state.GetDownload("download-1") - if err != nil { - t.Fatalf("failed to reload completed entry: %v", err) - } - if entry == nil { - t.Fatal("expected completed entry to exist") - } - if entry.Status != "completed" { - t.Fatalf("status = %q, want completed", entry.Status) - } - if entry.Workers != 8 { - t.Fatalf("Workers = %d, want 8 (preserved from existing entry)", entry.Workers) - } - if entry.MinChunkSize != 4*utils.MiB { - t.Fatalf("MinChunkSize = %d, want %d (preserved from existing entry)", entry.MinChunkSize, 4*utils.MiB) - } -} - -func TestStartEventWorker_PersistsQueuedMirrorsForResume(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - finalPath := filepath.Join(tempDir, "video.mp4") - - mgr := processing.NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 1) - ch <- events.DownloadQueuedMsg{ - DownloadID: "download-queued", - URL: "https://example.com/video.mp4", - Filename: "video.mp4", - DestPath: finalPath, - Mirrors: []string{"https://mirror-1.example/video.mp4", "https://mirror-2.example/video.mp4"}, - } - close(ch) - - mgr.StartEventWorker(ch) - - queuedState, err := state.GetDownload("download-queued") - if err != nil { - t.Fatalf("failed to reload queued state: %v", err) - } - if queuedState == nil { - t.Fatal("expected queued state entry to exist") - return - } - if len(queuedState.Mirrors) != 2 { - t.Fatalf("mirrors = %v, want 2 queued mirrors", queuedState.Mirrors) - } - if queuedState.Mirrors[0] != "https://mirror-1.example/video.mp4" || queuedState.Mirrors[1] != "https://mirror-2.example/video.mp4" { - t.Fatalf("mirrors = %v, want queued mirrors to round-trip", queuedState.Mirrors) - } -} - -func TestStartEventWorker_PreservesQueuedMirrorsAcrossStartedThenError(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - finalPath := filepath.Join(tempDir, "video.mp4") - - mgr := processing.NewLifecycleManager(nil, nil) - ch := make(chan interface{}, 3) - ch <- events.DownloadQueuedMsg{ - DownloadID: "download-queued", - URL: "https://example.com/video.mp4", - Filename: "video.mp4", - DestPath: finalPath, - Mirrors: []string{"https://mirror-1.example/video.mp4", "https://mirror-2.example/video.mp4"}, - } - ch <- events.DownloadStartedMsg{ - DownloadID: "download-queued", - URL: "https://example.com/video.mp4", - Filename: "video.mp4", - Total: 1024, - DestPath: finalPath, - } - ch <- events.DownloadErrorMsg{ - DownloadID: "download-queued", - Filename: "video.mp4", - DestPath: finalPath, - Err: os.ErrDeadlineExceeded, - } - close(ch) - - mgr.StartEventWorker(ch) - - entry, err := state.GetDownload("download-queued") - if err != nil { - t.Fatalf("failed to reload errored entry: %v", err) - } - if entry == nil { - t.Fatal("expected errored entry to exist") - return - } - if entry.Status != "error" { - t.Fatalf("status = %q, want error", entry.Status) - } - if len(entry.Mirrors) != 2 { - t.Fatalf("mirrors = %v, want queued mirrors to survive started/error", entry.Mirrors) - } - if entry.Mirrors[0] != "https://mirror-1.example/video.mp4" || entry.Mirrors[1] != "https://mirror-2.example/video.mp4" { - t.Fatalf("mirrors = %v, want queued mirrors to round-trip", entry.Mirrors) - } -} diff --git a/internal/processing/events_windows_test.go b/internal/processing/events_windows_test.go deleted file mode 100644 index 299fc804e..000000000 --- a/internal/processing/events_windows_test.go +++ /dev/null @@ -1,40 +0,0 @@ -//go:build windows - -package processing - -import ( - "os" - "path/filepath" - "testing" - - "github.com/SurgeDM/Surge/internal/engine/types" -) - -func TestFinalizeCompletedFile_OverwritesExistingDestinationOnWindows(t *testing.T) { - tempDir := t.TempDir() - finalPath := filepath.Join(tempDir, "video.mp4") - surgePath := finalPath + types.IncompleteSuffix - - if err := os.WriteFile(finalPath, []byte("old"), 0o644); err != nil { - t.Fatalf("failed to create existing final file: %v", err) - } - if err := os.WriteFile(surgePath, []byte("new"), 0o644); err != nil { - t.Fatalf("failed to create working file: %v", err) - } - - if err := finalizeCompletedFile(finalPath); err != nil { - t.Fatalf("finalizeCompletedFile returned unexpected error: %v", err) - } - - finalData, err := os.ReadFile(finalPath) - if err != nil { - t.Fatalf("failed to read final file: %v", err) - } - if string(finalData) != "new" { - t.Fatalf("final file content = %q, want %q", string(finalData), "new") - } - - if _, err := os.Stat(surgePath); !os.IsNotExist(err) { - t.Fatalf("expected working file to be removed after overwrite, stat err: %v", err) - } -} diff --git a/internal/processing/manager_override_test.go b/internal/processing/manager_override_test.go deleted file mode 100644 index 1589bb401..000000000 --- a/internal/processing/manager_override_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package processing - -import ( - "context" - "testing" -) - -func TestEnqueue_PerTaskOverrideForwarding(t *testing.T) { - tests := []struct { - name string - workers int - minChunkSize int64 - wantWorkers int - wantMinChunk int64 - }{ - { - name: "zero values stay zero", - workers: 0, - minChunkSize: 0, - wantWorkers: 0, - wantMinChunk: 0, - }, - { - name: "Enqueue forwards both fields", - workers: 8, - minChunkSize: 5 * 1024 * 1024, - wantWorkers: 8, - wantMinChunk: 5 * 1024 * 1024, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr := newLifecycleManagerForTest() - - var gotWorkers int - var gotMinChunkSize int64 - mgr.addFunc = func(_, _, _ string, _ []string, _ map[string]string, _ bool, workers int, minChunkSize int64, _ int64, _ bool) (string, error) { - gotWorkers = workers - gotMinChunkSize = minChunkSize - return "id", nil - } - - server := newProbeTestServer(t, 1024) - defer server.Close() - - _, _, err := mgr.Enqueue(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: "test.bin", - Path: t.TempDir(), - Workers: tt.workers, - MinChunkSize: tt.minChunkSize, - }) - if err != nil { - t.Fatalf("Enqueue failed: %v", err) - } - if gotWorkers != tt.wantWorkers { - t.Fatalf("expected workers=%d, got %d", tt.wantWorkers, gotWorkers) - } - if gotMinChunkSize != tt.wantMinChunk { - t.Fatalf("expected minChunkSize=%d, got %d", tt.wantMinChunk, gotMinChunkSize) - } - }) - } -} - -func TestEnqueueWithID_PerTaskOverrideForwarding(t *testing.T) { - tests := []struct { - name string - workers int - minChunkSize int64 - wantWorkers int - wantMinChunk int64 - }{ - { - name: "zero values stay zero", - workers: 0, - minChunkSize: 0, - wantWorkers: 0, - wantMinChunk: 0, - }, - { - name: "EnqueueWithID forwards both fields", - workers: 8, - minChunkSize: 5 * 1024 * 1024, - wantWorkers: 8, - wantMinChunk: 5 * 1024 * 1024, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mgr := newLifecycleManagerForTest() - - var gotWorkers int - var gotMinChunkSize int64 - mgr.addWithIDFunc = func(_, _, _ string, _ []string, _ map[string]string, _ string, workers int, minChunkSize int64, _ int64, _ bool) (string, error) { - gotWorkers = workers - gotMinChunkSize = minChunkSize - return "id", nil - } - - server := newProbeTestServer(t, 1024) - defer server.Close() - - _, _, err := mgr.EnqueueWithID(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: "test.bin", - Path: t.TempDir(), - Workers: tt.workers, - MinChunkSize: tt.minChunkSize, - }, "req-1") - if err != nil { - t.Fatalf("EnqueueWithID failed: %v", err) - } - if gotWorkers != tt.wantWorkers { - t.Fatalf("expected workers=%d, got %d", tt.wantWorkers, gotWorkers) - } - if gotMinChunkSize != tt.wantMinChunk { - t.Fatalf("expected minChunkSize=%d, got %d", tt.wantMinChunk, gotMinChunkSize) - } - }) - } -} diff --git a/internal/processing/manager_test.go b/internal/processing/manager_test.go deleted file mode 100644 index 6d5b718fc..000000000 --- a/internal/processing/manager_test.go +++ /dev/null @@ -1,1157 +0,0 @@ -package processing - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/testutil" -) - -func newProbeTestServer(t *testing.T, size int64) *httptest.Server { - t.Helper() - - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("Range"); got != "bytes=0-0" { - t.Fatalf("Range header = %q, want bytes=0-0", got) - } - - w.Header().Set("Content-Range", fmt.Sprintf("bytes 0-0/%d", size)) - w.Header().Set("Content-Length", "1") - w.WriteHeader(http.StatusPartialContent) - _, _ = w.Write([]byte("x")) - })) -} - -func newLifecycleManagerForTest() *LifecycleManager { - settings := config.DefaultSettings() - settings.Categories.CategoryEnabled.Value = false - sem := make(chan struct{}, maxConcurrentProbes) - for i := 0; i < maxConcurrentProbes; i++ { - sem <- struct{}{} - } - return &LifecycleManager{ - settings: settings, - settingsRefreshedAt: time.Now(), - probeSem: sem, - } -} - -func TestLifecycleManager_Enqueue_PrecreatesWorkingFileBeforeDispatch(t *testing.T) { - server := newProbeTestServer(t, 1234) - defer server.Close() - - tempDir := t.TempDir() - expectedFile := "archive.zip" - expectedID := "enqueue-id" - - mgr := newLifecycleManagerForTest() - mgr.addFunc = func(url, path, filename string, _ []string, _ map[string]string, explicit bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - if url != server.URL { - t.Fatalf("url = %q, want %q", url, server.URL) - } - if path != tempDir { - t.Fatalf("path = %q, want %q", path, tempDir) - } - if filename != expectedFile { - t.Fatalf("filename = %q, want %q", filename, expectedFile) - } - if !explicit { - t.Fatal("expected explicit category flag to be preserved") - } - if totalSize != 1234 { - t.Fatalf("totalSize = %d, want 1234", totalSize) - } - if !supportsRange { - t.Fatal("expected range support from probe") - } - - surgePath := filepath.Join(path, filename) + types.IncompleteSuffix - if _, err := os.Stat(surgePath); err != nil { - t.Fatalf("expected working file to exist before dispatch: %v", err) - } - - return expectedID, nil - } - - req := &DownloadRequest{ - URL: server.URL, - Filename: expectedFile, - Path: tempDir, - IsExplicitCategory: true, - } - - id, _, err := mgr.Enqueue(context.Background(), req) - if err != nil { - t.Fatalf("Enqueue failed: %v", err) - } - if id != expectedID { - t.Fatalf("id = %q, want %q", id, expectedID) - } - - surgePath := filepath.Join(tempDir, expectedFile) + types.IncompleteSuffix - if _, err := os.Stat(surgePath); err != nil { - t.Fatalf("expected working file to remain after queueing: %v", err) - } -} - -func TestLifecycleManager_EnqueueWithID_PrecreatesWorkingFileBeforeDispatch(t *testing.T) { - server := newProbeTestServer(t, 4321) - defer server.Close() - - tempDir := t.TempDir() - expectedFile := "archive.zip" - expectedID := "request-id" - - mgr := newLifecycleManagerForTest() - mgr.addWithIDFunc = func(url, path, filename string, _ []string, _ map[string]string, requestID string, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - if url != server.URL { - t.Fatalf("url = %q, want %q", url, server.URL) - } - if path != tempDir { - t.Fatalf("path = %q, want %q", path, tempDir) - } - if filename != expectedFile { - t.Fatalf("filename = %q, want %q", filename, expectedFile) - } - if requestID != expectedID { - t.Fatalf("requestID = %q, want %q", requestID, expectedID) - } - if totalSize != 4321 { - t.Fatalf("totalSize = %d, want 4321", totalSize) - } - if !supportsRange { - t.Fatal("expected range support from probe") - } - - surgePath := filepath.Join(path, filename) + types.IncompleteSuffix - if _, err := os.Stat(surgePath); err != nil { - t.Fatalf("expected working file to exist before dispatch: %v", err) - } - - return requestID, nil - } - - req := &DownloadRequest{ - URL: server.URL, - Filename: expectedFile, - Path: tempDir, - IsExplicitCategory: true, - } - - id, _, err := mgr.EnqueueWithID(context.Background(), req, expectedID) - if err != nil { - t.Fatalf("EnqueueWithID failed: %v", err) - } - if id != expectedID { - t.Fatalf("id = %q, want %q", id, expectedID) - } - - surgePath := filepath.Join(tempDir, expectedFile) + types.IncompleteSuffix - if _, err := os.Stat(surgePath); err != nil { - t.Fatalf("expected working file to remain after queueing: %v", err) - } -} - -func TestLifecycleManager_EnqueueWithID_ProbeFailureStillDispatches(t *testing.T) { - server := testutil.NewHTTPServerT(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusForbidden) - })) - defer server.Close() - - tempDir := t.TempDir() - expectedID := "request-id" - - mgr := newLifecycleManagerForTest() - mgr.addWithIDFunc = func(url, path, filename string, _ []string, _ map[string]string, requestID string, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - if url != server.URL { - t.Fatalf("url = %q, want %q", url, server.URL) - } - if path != tempDir { - t.Fatalf("path = %q, want %q", path, tempDir) - } - if filename != "fallback.bin" { - t.Fatalf("filename = %q, want fallback.bin", filename) - } - if requestID != expectedID { - t.Fatalf("requestID = %q, want %q", requestID, expectedID) - } - if totalSize != 0 { - t.Fatalf("totalSize = %d, want 0", totalSize) - } - if !supportsRange { - t.Fatal("expected optimistic concurrent flag after probe failure") - } - return requestID, nil - } - - id, filename, err := mgr.EnqueueWithID(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: "fallback.bin", - Path: tempDir, - }, expectedID) - if err != nil { - t.Fatalf("EnqueueWithID failed: %v", err) - } - if id != expectedID { - t.Fatalf("id = %q, want %q", id, expectedID) - } - if filename != "fallback.bin" { - t.Fatalf("filename = %q, want fallback.bin", filename) - } -} - -func TestLifecycleManager_Enqueue_RemovesWorkingFileOnDispatchError(t *testing.T) { - server := newProbeTestServer(t, 2048) - defer server.Close() - - tempDir := t.TempDir() - expectedFile := "broken.zip" - expectedErr := errors.New("dispatch failed") - - mgr := newLifecycleManagerForTest() - mgr.addFunc = func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { - return "", expectedErr - } - - _, _, err := mgr.Enqueue(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: expectedFile, - Path: tempDir, - IsExplicitCategory: true, - }) - if !errors.Is(err, expectedErr) { - t.Fatalf("err = %v, want %v", err, expectedErr) - } - - surgePath := filepath.Join(tempDir, expectedFile) + types.IncompleteSuffix - if _, statErr := os.Stat(surgePath); !os.IsNotExist(statErr) { - t.Fatalf("expected working file cleanup after dispatch failure, stat err: %v", statErr) - } -} - -func TestLifecycleManager_Enqueue_RetriesWhenWorkingFileReservationCollides(t *testing.T) { - server := newProbeTestServer(t, 1024) - defer server.Close() - - tempDir := t.TempDir() - - origReserve := reserveWorkingFile - t.Cleanup(func() { - reserveWorkingFile = origReserve - }) - - var reserveCalls int - reserveWorkingFile = func(destPath, filename string) error { - reserveCalls++ - if reserveCalls == 1 { - surgePath := filepath.Join(destPath, filename) + types.IncompleteSuffix - if err := os.MkdirAll(destPath, 0o755); err != nil { - t.Fatalf("failed to create temp dir for collision: %v", err) - } - if err := os.WriteFile(surgePath, []byte("occupied"), 0o644); err != nil { - t.Fatalf("failed to seed colliding working file: %v", err) - } - return fmt.Errorf("collision: %w", os.ErrExist) - } - return precreateWorkingFile(destPath, filename) - } - - mgr := newLifecycleManagerForTest() - var dispatchedFilename string - mgr.addFunc = func(url, path, filename string, _ []string, _ map[string]string, explicit bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - dispatchedFilename = filename - if path != tempDir { - t.Fatalf("path = %q, want %q", path, tempDir) - } - if explicit != true { - t.Fatal("expected explicit category flag to be preserved") - } - if totalSize != 1024 || !supportsRange { - t.Fatalf("unexpected probe metadata: total=%d range=%v", totalSize, supportsRange) - } - return "retry-id", nil - } - - id, _, err := mgr.Enqueue(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: "archive.zip", - Path: tempDir, - IsExplicitCategory: true, - }) - if err != nil { - t.Fatalf("Enqueue failed: %v", err) - } - if id != "retry-id" { - t.Fatalf("id = %q, want retry-id", id) - } - if dispatchedFilename != "archive(1).zip" { - t.Fatalf("filename = %q, want archive(1).zip", dispatchedFilename) - } - if reserveCalls < 2 { - t.Fatalf("reserve calls = %d, want at least 2", reserveCalls) - } - - firstSurgePath := filepath.Join(tempDir, "archive.zip") + types.IncompleteSuffix - if _, err := os.Stat(firstSurgePath); err != nil { - t.Fatalf("expected first reservation to remain in place: %v", err) - } - retriedSurgePath := filepath.Join(tempDir, "archive(1).zip") + types.IncompleteSuffix - if _, err := os.Stat(retriedSurgePath); err != nil { - t.Fatalf("expected retried reservation to exist: %v", err) - } -} - -func TestLifecycleManager_EnqueueWithID_RetriesWhenWorkingFileReservationCollides(t *testing.T) { - server := newProbeTestServer(t, 1024) - defer server.Close() - - tempDir := t.TempDir() - requestID := "request-id" - - origReserve := reserveWorkingFile - t.Cleanup(func() { - reserveWorkingFile = origReserve - }) - - var reserveCalls int - reserveWorkingFile = func(destPath, filename string) error { - reserveCalls++ - if reserveCalls == 1 { - surgePath := filepath.Join(destPath, filename) + types.IncompleteSuffix - if err := os.MkdirAll(destPath, 0o755); err != nil { - t.Fatalf("failed to create temp dir for collision: %v", err) - } - if err := os.WriteFile(surgePath, []byte("occupied"), 0o644); err != nil { - t.Fatalf("failed to seed colliding working file: %v", err) - } - return fmt.Errorf("collision: %w", os.ErrExist) - } - return precreateWorkingFile(destPath, filename) - } - - mgr := newLifecycleManagerForTest() - var dispatchedFilename string - mgr.addWithIDFunc = func(url, path, filename string, _ []string, _ map[string]string, gotRequestID string, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { - dispatchedFilename = filename - if path != tempDir { - t.Fatalf("path = %q, want %q", path, tempDir) - } - if gotRequestID != requestID { - t.Fatalf("requestID = %q, want %q", gotRequestID, requestID) - } - if totalSize != 1024 || !supportsRange { - t.Fatalf("unexpected probe metadata: total=%d range=%v", totalSize, supportsRange) - } - return gotRequestID, nil - } - - id, _, err := mgr.EnqueueWithID(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: "archive.zip", - Path: tempDir, - IsExplicitCategory: true, - }, requestID) - if err != nil { - t.Fatalf("EnqueueWithID failed: %v", err) - } - if id != requestID { - t.Fatalf("id = %q, want %q", id, requestID) - } - if dispatchedFilename != "archive(1).zip" { - t.Fatalf("filename = %q, want archive(1).zip", dispatchedFilename) - } - if reserveCalls < 2 { - t.Fatalf("reserve calls = %d, want at least 2", reserveCalls) - } - - firstSurgePath := filepath.Join(tempDir, "archive.zip") + types.IncompleteSuffix - if _, err := os.Stat(firstSurgePath); err != nil { - t.Fatalf("expected first reservation to remain in place: %v", err) - } - retriedSurgePath := filepath.Join(tempDir, "archive(1).zip") + types.IncompleteSuffix - if _, err := os.Stat(retriedSurgePath); err != nil { - t.Fatalf("expected retried reservation to exist: %v", err) - } -} - -func TestLifecycleManager_EnqueueWithID_RemovesWorkingFileOnDispatchError(t *testing.T) { - server := newProbeTestServer(t, 2048) - defer server.Close() - - tempDir := t.TempDir() - expectedFile := "broken.zip" - expectedErr := errors.New("dispatch failed") - - mgr := newLifecycleManagerForTest() - mgr.addWithIDFunc = func(string, string, string, []string, map[string]string, string, int, int64, int64, bool) (string, error) { - return "", expectedErr - } - - _, _, err := mgr.EnqueueWithID(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: expectedFile, - Path: tempDir, - IsExplicitCategory: true, - }, "request-id") - if !errors.Is(err, expectedErr) { - t.Fatalf("err = %v, want %v", err, expectedErr) - } - - surgePath := filepath.Join(tempDir, expectedFile) + types.IncompleteSuffix - if _, statErr := os.Stat(surgePath); !os.IsNotExist(statErr) { - t.Fatalf("expected working file cleanup after dispatch failure, stat err: %v", statErr) - } -} - -func TestLifecycleManager_Enqueue_FailsAfterReservationAttemptLimit(t *testing.T) { - server := newProbeTestServer(t, 2048) - defer server.Close() - - tempDir := t.TempDir() - - origReserve := reserveWorkingFile - origTTL := settingsRefreshTTL - t.Cleanup(func() { - reserveWorkingFile = origReserve - settingsRefreshTTL = origTTL - }) - settingsRefreshTTL = time.Hour - - var reserveCalls int - reserveWorkingFile = func(string, string) error { - reserveCalls++ - return fmt.Errorf("collision: %w", os.ErrExist) - } - - mgr := newLifecycleManagerForTest() - mgr.addFunc = func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { - t.Fatal("dispatch should not run when reservation never succeeds") - return "", nil - } - - _, _, err := mgr.Enqueue(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: "archive.zip", - Path: tempDir, - IsExplicitCategory: true, - }) - if err == nil { - t.Fatal("expected reservation exhaustion error") - } - if reserveCalls != maxWorkingFileReservationAttempts { - t.Fatalf("reserve calls = %d, want %d", reserveCalls, maxWorkingFileReservationAttempts) - } -} - -func TestLifecycleManager_GetSettings_RefreshesFromDiskAfterTTL(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) - - initial := config.DefaultSettings() - initial.Categories.CategoryEnabled.Value = false - if err := config.SaveSettings(initial); err != nil { - t.Fatalf("SaveSettings(initial) failed: %v", err) - } - - mgr := NewLifecycleManager(nil, nil) - - updated := config.DefaultSettings() - updated.Categories.CategoryEnabled.Value = true - if err := config.SaveSettings(updated); err != nil { - t.Fatalf("SaveSettings(updated) failed: %v", err) - } - - origTTL := settingsRefreshTTL - t.Cleanup(func() { - settingsRefreshTTL = origTTL - }) - settingsRefreshTTL = 0 - - settings := mgr.GetSettings() - if !config.Resolve[bool](settings.Categories.CategoryEnabled) { - t.Fatal("expected GetSettings to pick up saved settings after TTL expiry") - } -} - -func TestLifecycleManager_GetSettings_KeepsCachedSnapshotWhenReloadFails(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmpDir) - - initial := config.DefaultSettings() - initial.General.WarnOnDuplicate.Value = false - if err := config.SaveSettings(initial); err != nil { - t.Fatalf("SaveSettings(initial) failed: %v", err) - } - - mgr := NewLifecycleManager(nil, nil) - - badConfigHome := filepath.Join(tmpDir, "bad-config-home") - if err := os.WriteFile(badConfigHome, []byte("not a directory"), 0o644); err != nil { - t.Fatalf("WriteFile(badConfigHome) failed: %v", err) - } - t.Setenv("XDG_CONFIG_HOME", badConfigHome) - - origTTL := settingsRefreshTTL - t.Cleanup(func() { - settingsRefreshTTL = origTTL - }) - settingsRefreshTTL = 0 - - settings := mgr.GetSettings() - if config.Resolve[bool](settings.General.WarnOnDuplicate) { - t.Fatal("expected GetSettings to keep the cached snapshot when disk reload fails") - } -} - -func TestLifecycleManager_Enqueue_ContextCancellationDuringProbe(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-r.Context().Done() - })) - defer server.Close() - - mgr := newLifecycleManagerForTest() - mgr.addFunc = func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { - t.Fatal("dispatch should not run when probe fails") - return "", nil - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - _, _, err := mgr.Enqueue(ctx, &DownloadRequest{ - URL: server.URL, - Filename: "test.zip", - Path: t.TempDir(), - }) - - if err == nil { - t.Fatal("expected error due to context cancellation, got nil") - } - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", err) - } -} - -func TestLifecycleManager_EnqueueWithID_FailsAfterReservationAttemptLimit(t *testing.T) { - server := newProbeTestServer(t, 2048) - defer server.Close() - - tempDir := t.TempDir() - - origReserve := reserveWorkingFile - origTTL := settingsRefreshTTL - t.Cleanup(func() { - reserveWorkingFile = origReserve - settingsRefreshTTL = origTTL - }) - settingsRefreshTTL = time.Hour - - var reserveCalls int - reserveWorkingFile = func(string, string) error { - reserveCalls++ - return fmt.Errorf("collision: %w", os.ErrExist) - } - - mgr := newLifecycleManagerForTest() - mgr.addWithIDFunc = func(string, string, string, []string, map[string]string, string, int, int64, int64, bool) (string, error) { - t.Fatal("dispatch should not run when reservation never succeeds") - return "", nil - } - - _, _, err := mgr.EnqueueWithID(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: "archive.zip", - Path: tempDir, - IsExplicitCategory: true, - }, "test-id") - - if err == nil { - t.Fatal("expected reservation exhaustion error") - } - if reserveCalls != maxWorkingFileReservationAttempts { - t.Fatalf("reserve calls = %d, want %d", reserveCalls, maxWorkingFileReservationAttempts) - } -} - -func TestLifecycleManager_Enqueue_EmptyURL(t *testing.T) { - mgr := newLifecycleManagerForTest() - _, _, err := mgr.Enqueue(context.Background(), &DownloadRequest{ - URL: "", - Path: t.TempDir(), - }) - if err == nil { - t.Fatal("expected error with empty URL") - } -} - -func TestLifecycleManager_Enqueue_EmptyPath(t *testing.T) { - server := newProbeTestServer(t, 2048) - defer server.Close() - mgr := newLifecycleManagerForTest() - _, _, err := mgr.Enqueue(context.Background(), &DownloadRequest{ - URL: server.URL, - Path: "", - }) - if err == nil { - t.Fatal("expected error with empty path") - } -} - -func TestLifecycleManager_Enqueue_ContextCancellationBeforeReservation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Range", "bytes 0-0/2048") - w.Header().Set("Content-Length", "1") - w.WriteHeader(http.StatusPartialContent) - _, _ = w.Write([]byte("x")) - // Cancel context so it takes effect right after probe returns - cancel() - })) - defer server.Close() - - mgr := newLifecycleManagerForTest() - mgr.addFunc = func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { - t.Fatal("dispatch should not run when context is canceled before reservation") - return "", nil - } - - _, _, err := mgr.Enqueue(ctx, &DownloadRequest{ - URL: server.URL, - Filename: "test.zip", - Path: t.TempDir(), - }) - - if err == nil { - t.Fatal("expected error due to context cancellation, got nil") - } - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", err) - } -} - -// --- LifecycleManager.Resume / Cancel / UpdateURL Tests --- - -func TestLifecycleManager_Resume_HotPath(t *testing.T) { - var extractCalled, addCalled bool - var publishedEvent interface{} - - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - GetStatus: func(id string) *types.DownloadStatus { - return &types.DownloadStatus{Status: "paused"} - }, - ExtractPausedConfig: func(id string) *types.DownloadConfig { - extractCalled = true - return &types.DownloadConfig{ID: "hot-id", Filename: "hot-file.zip"} - }, - AddConfig: func(cfg types.DownloadConfig) { - addCalled = true - if cfg.ID != "hot-id" { - t.Errorf("AddConfig ID = %q, want hot-id", cfg.ID) - } - if !cfg.IsResume { - t.Error("expected IsResume flag to be set") - } - }, - PublishEvent: func(msg interface{}) error { - publishedEvent = msg - return nil - }, - }) - - if err := mgr.Resume("hot-id"); err != nil { - t.Fatalf("Resume failed: %v", err) - } - if !extractCalled { - t.Error("Expected ExtractPausedConfig to be called for hot path") - } - if !addCalled { - t.Error("Expected AddConfig to be called") - } - if publishedEvent == nil { - t.Fatal("Expected PublishEvent to be called") - } - msg, ok := publishedEvent.(events.DownloadResumedMsg) - if !ok { - t.Fatalf("Expected DownloadResumedMsg, got %T", publishedEvent) - } - if msg.DownloadID != "hot-id" { - t.Errorf("ResumedMsg.DownloadID = %q, want hot-id", msg.DownloadID) - } - if msg.Filename != "hot-file.zip" { - t.Errorf("ResumedMsg.Filename = %q, want hot-file.zip", msg.Filename) - } -} - -func TestLifecycleManager_Resume_ColdPath(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - destPath := filepath.Join(tempDir, "cold-file.zip") - - testutil.SeedMasterList(t, types.DownloadEntry{ - ID: "cold-id", - URL: "http://example.com/cold.zip", - URLHash: state.URLHash("http://example.com/cold.zip"), - DestPath: destPath, - Filename: "cold-file.zip", - Status: "paused", - Downloaded: 500, - TotalSize: 1000, - }) - - var addCalled bool - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - ExtractPausedConfig: func(id string) *types.DownloadConfig { return nil }, - AddConfig: func(cfg types.DownloadConfig) { - addCalled = true - if cfg.ID != "cold-id" { - t.Errorf("AddConfig ID = %q, want cold-id", cfg.ID) - } - if !cfg.IsResume { - t.Error("expected IsResume flag") - } - }, - }) - - if err := mgr.Resume("cold-id"); err != nil { - t.Fatalf("Resume failed: %v", err) - } - if !addCalled { - t.Error("Expected AddConfig to be called for cold path") - } -} - -func TestLifecycleManager_Resume_NotFound(t *testing.T) { - testutil.SetupStateDB(t) - - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - ExtractPausedConfig: func(id string) *types.DownloadConfig { return nil }, - }) - - err := mgr.Resume("missing-id") - if err == nil { - t.Fatal("expected error for unknown download") - } -} - -func TestLifecycleManager_Resume_Completed(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - - testutil.SeedMasterList(t, types.DownloadEntry{ - ID: "done-id", - URL: "http://example.com/done.zip", - URLHash: state.URLHash("http://example.com/done.zip"), - DestPath: filepath.Join(tempDir, "done.zip"), - Filename: "done.zip", - Status: "completed", - }) - - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - ExtractPausedConfig: func(id string) *types.DownloadConfig { return nil }, - }) - - err := mgr.Resume("done-id") - if err == nil { - t.Fatal("expected error for completed download") - } -} - -func TestLifecycleManager_Resume_StillPausing(t *testing.T) { - var extraCalled bool - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - GetStatus: func(id string) *types.DownloadStatus { - return &types.DownloadStatus{Status: "pausing"} - }, - ExtractPausedConfig: func(id string) *types.DownloadConfig { - extraCalled = true - return nil - }, - }) - - err := mgr.Resume("pausing-id") - if err == nil { - t.Fatal("expected error when download is still pausing") - } - if extraCalled { - t.Error("Expected ExtractPausedConfig to NOT be called while pausing") - } -} - -func TestLifecycleManager_Resume_HydratesFromDisk(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - destPath := filepath.Join(tempDir, "hydrated.zip") - - testutil.SeedMasterList(t, types.DownloadEntry{ - ID: "hydrate-id", - URL: "http://example.com/hydrated.zip", - URLHash: state.URLHash("http://example.com/hydrated.zip"), - DestPath: destPath, - Filename: "hydrated.zip", - Status: "paused", - }) - - if err := state.SaveStateWithOptions("http://example.com/hydrated.zip", destPath, &types.DownloadState{ - ID: "hydrate-id", URL: "http://example.com/hydrated.zip", Filename: "hydrated.zip", - DestPath: destPath, TotalSize: 5000, - Tasks: []types.Task{{Offset: 0, Length: 2500}, {Offset: 2500, Length: 2500}}, - }, state.SaveStateOptions{SkipFileHash: true}); err != nil { - t.Fatalf("failed to seed state: %v", err) - } - - var addedCfg *types.DownloadConfig - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - GetStatus: func(id string) *types.DownloadStatus { return &types.DownloadStatus{Status: "paused"} }, - ExtractPausedConfig: func(id string) *types.DownloadConfig { - return &types.DownloadConfig{ID: id, Filename: "hydrated.zip", URL: "http://example.com/hydrated.zip", DestPath: destPath} - }, - AddConfig: func(cfg types.DownloadConfig) { addedCfg = &cfg }, - }) - - if err := mgr.Resume("hydrate-id"); err != nil { - t.Fatalf("Resume failed: %v", err) - } - if addedCfg == nil { - t.Fatal("Expected AddConfig to be called") - } - if addedCfg.TotalSize != 5000 { - t.Errorf("TotalSize = %d, want 5000", addedCfg.TotalSize) - } - if !addedCfg.SupportsRange { - t.Error("Expected SupportsRange = true after loading tasks") - } -} - -func TestLifecycleManager_Cancel_FromPool(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - - testutil.SeedMasterList(t, types.DownloadEntry{ - ID: "active-cancel", - URL: "http://example.com/active.zip", - URLHash: state.URLHash("http://example.com/active.zip"), - DestPath: filepath.Join(tempDir, "active.zip"), - Filename: "active.zip", - Status: "downloading", - }) - - var publishedMsg interface{} - cancelResult := types.CancelResult{Found: true, Filename: "active.zip", DestPath: filepath.Join(tempDir, "active.zip")} - - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - Cancel: func(id string) types.CancelResult { return cancelResult }, - PublishEvent: func(msg interface{}) error { publishedMsg = msg; return nil }, - }) - - if err := mgr.Cancel("active-cancel"); err != nil { - t.Fatalf("Cancel failed: %v", err) - } - if publishedMsg == nil { - t.Fatal("Expected DownloadRemovedMsg to be published") - } - removed, ok := publishedMsg.(events.DownloadRemovedMsg) - if !ok { - t.Fatalf("Expected DownloadRemovedMsg, got %T", publishedMsg) - } - if removed.DownloadID != "active-cancel" { - t.Errorf("RemovedMsg.DownloadID = %q, want active-cancel", removed.DownloadID) - } - if removed.Filename != "active.zip" { - t.Errorf("RemovedMsg.Filename = %q, want active.zip", removed.Filename) - } -} - -func TestLifecycleManager_Cancel_DBOnly(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - destPath := filepath.Join(tempDir, "db-only.zip") - - testutil.SeedMasterList(t, types.DownloadEntry{ - ID: "db-only", - URL: "http://example.com/db-only.zip", - URLHash: state.URLHash("http://example.com/db-only.zip"), - DestPath: destPath, - Filename: "db-only.zip", - Status: "paused", - }) - - var publishedMsg interface{} - - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - Cancel: func(id string) types.CancelResult { return types.CancelResult{Found: false} }, - PublishEvent: func(msg interface{}) error { publishedMsg = msg; return nil }, - }) - - if err := mgr.Cancel("db-only"); err != nil { - t.Fatalf("Cancel failed: %v", err) - } - - removed, ok := publishedMsg.(events.DownloadRemovedMsg) - if !ok { - t.Fatalf("Expected DownloadRemovedMsg, got %T", publishedMsg) - } - if removed.DownloadID != "db-only" { - t.Errorf("RemovedMsg.DownloadID = %q, want db-only", removed.DownloadID) - } - if removed.Filename != "db-only.zip" { - t.Errorf("RemovedMsg.Filename = %q, want db-only.zip", removed.Filename) - } -} - -func TestLifecycleManager_Cancel_Completed(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - destPath := filepath.Join(tempDir, "completed.zip") - - testutil.SeedMasterList(t, types.DownloadEntry{ - ID: "completed-cancel", - URL: "http://example.com/completed.zip", - URLHash: state.URLHash("http://example.com/completed.zip"), - DestPath: destPath, - Filename: "completed.zip", - Status: "completed", - }) - - var publishedMsg interface{} - - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - Cancel: func(id string) types.CancelResult { return types.CancelResult{} }, - PublishEvent: func(msg interface{}) error { publishedMsg = msg; return nil }, - }) - - if err := mgr.Cancel("completed-cancel"); err != nil { - t.Fatalf("Cancel failed: %v", err) - } - - removed, ok := publishedMsg.(events.DownloadRemovedMsg) - if !ok { - t.Fatalf("Expected DownloadRemovedMsg, got %T", publishedMsg) - } - if !removed.Completed { - t.Error("Expected Completed=true for a completed download") - } - if removed.Filename != "completed.zip" { - t.Errorf("RemovedMsg.Filename = %q, want completed.zip", removed.Filename) - } -} - -func TestLifecycleManager_Cancel_NotFound(t *testing.T) { - testutil.SetupStateDB(t) - - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - Cancel: func(id string) types.CancelResult { return types.CancelResult{Found: false} }, - }) - - err := mgr.Cancel("ghost-id") - if err != nil { - t.Fatalf("expected nil error for non-existent download (idempotent), got %v", err) - } -} - -func TestLifecycleManager_UpdateURL_Success(t *testing.T) { - tempDir := testutil.SetupStateDB(t) - - testutil.SeedMasterList(t, types.DownloadEntry{ - ID: "update-id", - URL: "http://example.com/old.zip", - URLHash: state.URLHash("http://example.com/old.zip"), - DestPath: filepath.Join(tempDir, "update.zip"), - Filename: "update.zip", - Status: "paused", - }) - - var hookCalled bool - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - UpdateURL: func(id, newURL string) error { - hookCalled = true - if newURL != "http://example.com/new.zip" { - t.Errorf("UpdateURL newURL = %q", newURL) - } - return nil - }, - }) - - if err := mgr.UpdateURL("update-id", "http://example.com/new.zip"); err != nil { - t.Fatalf("UpdateURL failed: %v", err) - } - if !hookCalled { - t.Error("Expected UpdateURL hook to be called") - } - - entry, err := state.GetDownload("update-id") - if err != nil { - t.Fatalf("GetDownload failed: %v", err) - } - if entry == nil || entry.URL != "http://example.com/new.zip" { - t.Errorf("DB URL = %q, want http://example.com/new.zip", entry.URL) - } -} - -func TestLifecycleManager_UpdateURL_HookError(t *testing.T) { - testutil.SetupStateDB(t) - - expectedErr := fmt.Errorf("not in pausable state") - mgr := newLifecycleManagerForTest() - mgr.SetEngineHooks(EngineHooks{ - UpdateURL: func(id, newURL string) error { return expectedErr }, - }) - - err := mgr.UpdateURL("bad-id", "http://example.com/new.zip") - if err == nil { - t.Fatal("expected error from pool hook, got nil") - } -} - -// --- Probe semaphore tests --- - -// newSlowProbeServer creates an httptest server that serves 206 Partial Content -// but delays each response by `delay` so we can observe concurrency behaviour. -func newSlowProbeServer(t *testing.T, size int64, delay time.Duration, inflight *int32) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt32(inflight, 1) - defer atomic.AddInt32(inflight, -1) - time.Sleep(delay) - w.Header().Set("Content-Range", fmt.Sprintf("bytes 0-0/%d", size)) - w.Header().Set("Content-Length", "1") - w.WriteHeader(http.StatusPartialContent) - _, _ = w.Write([]byte("x")) - })) -} - -// TestLifecycleManager_ProbeSemaphore_LimitsInflight fires N concurrent Enqueue -// calls against a slow probe server and verifies the peak in-flight count never -// exceeds maxConcurrentProbes. -func TestLifecycleManager_ProbeSemaphore_LimitsInflight(t *testing.T) { - const numDownloads = 9 // 3× the semaphore cap - - var peak int32 // highest observed in-flight count - var inflight int32 - - server := newSlowProbeServer(t, 1024, 50*time.Millisecond, &inflight) - defer server.Close() - - // Wrap the probe so we can record the peak without changing production code. - // We do this by polling inflight from a separate goroutine while probes run. - stopPoller := make(chan struct{}) - go func() { - for { - select { - case <-stopPoller: - return - default: - cur := atomic.LoadInt32(&inflight) - for { - prev := atomic.LoadInt32(&peak) - if cur <= prev || atomic.CompareAndSwapInt32(&peak, prev, cur) { - break - } - } - time.Sleep(time.Millisecond) - } - } - }() - - mgr := newLifecycleManagerForTest() - mgr.addFunc = func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { - return "", fmt.Errorf("dispatch intentionally rejected for test") - } - settings := config.DefaultSettings() - settings.Categories.CategoryEnabled.Value = false - mgr.ApplySettings(settings) - - tempDir := t.TempDir() - - var wg sync.WaitGroup - errs := make([]error, numDownloads) - for i := 0; i < numDownloads; i++ { - wg.Add(1) - go func(idx int) { - defer wg.Done() - _, _, errs[idx] = mgr.Enqueue(context.Background(), &DownloadRequest{ - URL: server.URL, - Filename: fmt.Sprintf("file%d.zip", idx), - Path: tempDir, - }) - }(i) - } - wg.Wait() - close(stopPoller) - - // addFunc is nil so every enqueue fails after probe - that's fine; - // we only care that the probe phase was throttled. - for _, err := range errs { - if err == nil { - t.Error("expected Enqueue to fail (nil addFunc), got nil error") - } - } - - observed := atomic.LoadInt32(&peak) - if observed > maxConcurrentProbes { - t.Errorf("peak concurrent probes = %d, want ≤ %d", observed, maxConcurrentProbes) - } -} - -// TestLifecycleManager_ProbeSemaphore_CancelledContextAbortsWait verifies that -// a queued enqueue waiting on the semaphore returns immediately when its context -// is cancelled, without needing to wait for a slot to become available. -func TestLifecycleManager_ProbeSemaphore_CancelledContextAbortsWait(t *testing.T) { - // Build a manager and fill its semaphore completely so the next Enqueue blocks. - mgr := newLifecycleManagerForTest() - mgr.addFunc = func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { - t.Fatal("dispatch should not run when context is cancelled") - return "", nil - } - - // Drain all slots from the semaphore to simulate maxConcurrentProbes in-flight. - for i := 0; i < maxConcurrentProbes; i++ { - <-mgr.probeSem - } - // Schedule slot return after a generous delay to confirm the test doesn't wait for it. - go func() { - time.Sleep(500 * time.Millisecond) - for i := 0; i < maxConcurrentProbes; i++ { - mgr.probeSem <- struct{}{} - } - }() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // already cancelled - - start := time.Now() - _, _, err := mgr.Enqueue(ctx, &DownloadRequest{ - URL: "http://127.0.0.1:0/dummy", - Path: t.TempDir(), - }) - elapsed := time.Since(start) - - if err == nil { - t.Fatal("expected error due to cancelled context, got nil") - } - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected context.Canceled, got %v", err) - } - // Should abort almost instantly, not wait for the delayed slot return. - if elapsed > 200*time.Millisecond { - t.Errorf("Enqueue took %v to abort - semaphore cancellation may be broken", elapsed) - } -} diff --git a/internal/processing/pause_resume_override_test.go b/internal/processing/pause_resume_override_test.go deleted file mode 100644 index 3e5b024a5..000000000 --- a/internal/processing/pause_resume_override_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package processing - -import ( - "testing" - - "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/utils" -) - -func TestBuildResumeConfig_OverridesFromSavedState(t *testing.T) { - settings := config.DefaultSettings() - entry := &types.DownloadEntry{ - ID: "test-id", - URL: "http://example.com/file.zip", - DestPath: "/tmp/file.zip", - Filename: "file.zip", - TotalSize: 1000, - Workers: 4, - MinChunkSize: 5 * utils.MiB, - } - savedState := &types.DownloadState{ - ID: "test-id", - URL: "http://example.com/file.zip", - DestPath: "/tmp/file.zip", - Filename: "file.zip", - TotalSize: 1000, - Downloaded: 500, - Workers: 8, - MinChunkSize: 5 * utils.MiB, - } - - cfg := buildResumeConfig("test-id", "/tmp", entry, savedState, settings) - if cfg.Runtime.Workers != 8 { - t.Fatalf("expected Runtime.Workers=8 (from savedState), got %d", cfg.Runtime.Workers) - } - if cfg.Runtime.MinChunkSize != 5*utils.MiB { - t.Fatalf("expected Runtime.MinChunkSize=%d (from savedState), got %d", 5*utils.MiB, cfg.Runtime.MinChunkSize) - } -} - -func TestBuildResumeConfig_OverridesFallbackToEntry(t *testing.T) { - settings := config.DefaultSettings() - entry := &types.DownloadEntry{ - ID: "test-id", - URL: "http://example.com/file.zip", - DestPath: "/tmp/file.zip", - Filename: "file.zip", - TotalSize: 1000, - Workers: 8, - MinChunkSize: 5 * utils.MiB, - } - - cfg := buildResumeConfig("test-id", "/tmp", entry, nil, settings) - if cfg.Runtime.Workers != 8 { - t.Fatalf("expected Runtime.Workers=8 (from entry fallback), got %d", cfg.Runtime.Workers) - } - if cfg.Runtime.MinChunkSize != 5*utils.MiB { - t.Fatalf("expected Runtime.MinChunkSize=%d (from entry fallback), got %d", 5*utils.MiB, cfg.Runtime.MinChunkSize) - } -} - -func TestBuildResumeConfig_SavedStatePriorityForMinChunkSize(t *testing.T) { - settings := config.DefaultSettings() - entry := &types.DownloadEntry{ - ID: "test-id", - URL: "http://example.com/file.zip", - DestPath: "/tmp/file.zip", - Filename: "file.zip", - TotalSize: 1000, - Workers: 4, - MinChunkSize: 2 * utils.MiB, - } - savedState := &types.DownloadState{ - ID: "test-id", - URL: "http://example.com/file.zip", - DestPath: "/tmp/file.zip", - Filename: "file.zip", - TotalSize: 1000, - Downloaded: 500, - Workers: 8, - MinChunkSize: 10 * utils.MiB, - } - - cfg := buildResumeConfig("test-id", "/tmp", entry, savedState, settings) - if cfg.Runtime.Workers != 8 { - t.Fatalf("expected Runtime.Workers=8 (from savedState), got %d", cfg.Runtime.Workers) - } - if cfg.Runtime.MinChunkSize != 10*utils.MiB { - t.Fatalf("expected Runtime.MinChunkSize=%d (from savedState), got %d", 10*utils.MiB, cfg.Runtime.MinChunkSize) - } -} - -func TestBuildResumeConfig_NoOverridesUsesDefaults(t *testing.T) { - settings := config.DefaultSettings() - entry := &types.DownloadEntry{ - ID: "test-id", - URL: "http://example.com/file.zip", - DestPath: "/tmp/file.zip", - Filename: "file.zip", - TotalSize: 1000, - } - savedState := &types.DownloadState{ - ID: "test-id", - URL: "http://example.com/file.zip", - DestPath: "/tmp/file.zip", - Filename: "file.zip", - TotalSize: 1000, - Downloaded: 500, - } - - cfg := buildResumeConfig("test-id", "/tmp", entry, savedState, settings) - if cfg.Runtime.Workers != 0 { - t.Fatalf("expected Runtime.Workers=0 (unset), got %d", cfg.Runtime.Workers) - } - defaultRuntime := settings.ToRuntimeConfig() - if cfg.Runtime.MinChunkSize != defaultRuntime.MinChunkSize { - t.Fatalf("expected Runtime.MinChunkSize=%d (global default), got %d", defaultRuntime.MinChunkSize, cfg.Runtime.MinChunkSize) - } -} diff --git a/internal/engine/types/accuracy_test.go b/internal/progress/accuracy_test.go similarity index 89% rename from internal/engine/types/accuracy_test.go rename to internal/progress/accuracy_test.go index 33b8f57f6..457e94241 100644 --- a/internal/engine/types/accuracy_test.go +++ b/internal/progress/accuracy_test.go @@ -1,13 +1,14 @@ -package types_test +package progress_test import ( "testing" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/types" ) func TestChunkAccuracy(t *testing.T) { - state := types.NewProgressState("test", 100*1024*1024) // 100MB + state := progress.New("test", 100*1024*1024) // 100MB // Init 200 chunks -> 500KB per chunk // 10 MB total, 1 MB chunks @@ -52,7 +53,7 @@ func TestChunkAccuracy(t *testing.T) { } func TestRestoreBitmap(t *testing.T) { - state := types.NewProgressState("test-restore", 100*1024*1024) // 100MB + state := progress.New("test-restore", 100*1024*1024) // 100MB // Create a bitmap manually // 100MB / 1MB chunks = 100 chunks. @@ -68,13 +69,14 @@ func TestRestoreBitmap(t *testing.T) { // Restore state.RestoreBitmap(bitmap, 1024*1024) // 1MB chunk size - // Verify - if state.ActualChunkSize != 1024*1024 { - t.Errorf("Expected ActualChunkSize 1MB, got %d", state.ActualChunkSize) + _, width, _, actualChunkSize, _ := state.GetBitmap() + + if actualChunkSize != 1024*1024 { + t.Errorf("Expected ActualChunkSize 1MB, got %d", actualChunkSize) } - if state.BitmapWidth != 100 { - t.Errorf("Expected BitmapWidth 100, got %d", state.BitmapWidth) + if width != 100 { + t.Errorf("Expected BitmapWidth 100, got %d", width) } if state.GetChunkState(0) != types.ChunkCompleted { @@ -92,7 +94,7 @@ func TestRestoreBitmap_ShortBitmapRecoversWithoutPanic(t *testing.T) { chunkSize = 1 * 1024 * 1024 ) - state := types.NewProgressState("test-short-restore", totalSize) + state := progress.New("test-short-restore", totalSize) malformed := []byte{0x02} // Too short: only enough storage for 4 chunks. expectedBytes := 25 // 100 chunks * 2 bits = 25 bytes. @@ -137,7 +139,7 @@ func TestRestoreBitmap_ShortBitmapRecoversWithoutPanic(t *testing.T) { func TestRecalculateProgress(t *testing.T) { // 30MB total, 10MB chunks -> 3 chunks - state := types.NewProgressState("test-recalc", 30*1024*1024) + state := progress.New("test-recalc", 30*1024*1024) chunkSize := int64(10 * 1024 * 1024) state.InitBitmap(30*1024*1024, chunkSize) diff --git a/internal/progress/bitmap.go b/internal/progress/bitmap.go new file mode 100644 index 000000000..679639a89 --- /dev/null +++ b/internal/progress/bitmap.go @@ -0,0 +1,337 @@ +package progress + +import ( + "github.com/SurgeDM/Surge/internal/types" + "github.com/SurgeDM/Surge/internal/utils" + "sync" +) + +type BitmapTracker struct { + mu sync.Mutex + bitmap []byte + chunkProgress []int64 + actualChunkSize int64 + width int +} + +// bitmapLayout returns the number of tracked chunks and backing bytes for a +// 2-bit-per-chunk bitmap. +func bitmapLayout(totalSize, chunkSize int64) (numChunks int, bytesNeeded int, ok bool) { + if totalSize <= 0 || chunkSize <= 0 { + return 0, 0, false + } + + numChunks = int((totalSize + chunkSize - 1) / chunkSize) + if numChunks <= 0 { + return 0, 0, false + } + + bytesNeeded = (numChunks + 3) / 4 + return numChunks, bytesNeeded, true +} + +// Reset clears the bitmap completely. +func (b *BitmapTracker) Reset() { + b.mu.Lock() + defer b.mu.Unlock() + if b.width > 0 { + b.bitmap = make([]byte, len(b.bitmap)) + b.chunkProgress = make([]int64, b.width) + } +} + +// InitBitmap initializes the chunk bitmap. +func (b *BitmapTracker) InitBitmap(totalSize int64, chunkSize int64) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.bitmap) > 0 && b.actualChunkSize == chunkSize { + // Already initialized and the chunk size is correct. + // NOTE: TotalSize check is left to the caller or implicitly valid. + return + } + + utils.Debug("InitBitmap: Total=%d, ChunkSize=%d", totalSize, chunkSize) + if chunkSize <= 0 { + return + } + + numChunks, bytesNeeded, ok := bitmapLayout(totalSize, chunkSize) + if !ok { + return + } + + b.actualChunkSize = chunkSize + b.width = numChunks + b.bitmap = make([]byte, bytesNeeded) + b.chunkProgress = make([]int64, numChunks) +} + +// RestoreBitmap restores the chunk bitmap from saved state. +func (b *BitmapTracker) RestoreBitmap(totalSize int64, bitmap []byte, actualChunkSize int64) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(bitmap) == 0 || actualChunkSize <= 0 || totalSize <= 0 { + return + } + + numChunks, bytesNeeded, ok := bitmapLayout(totalSize, actualChunkSize) + if !ok { + return + } + + b.bitmap = make([]byte, bytesNeeded) + copy(b.bitmap, bitmap) + b.actualChunkSize = actualChunkSize + b.width = numChunks + + if len(b.chunkProgress) != numChunks { + b.chunkProgress = make([]int64, numChunks) + } +} + +// SetChunkProgress updates chunk progress array from external sources. +func (b *BitmapTracker) SetChunkProgress(progress []int64) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(progress) == 0 { + return + } + if len(b.chunkProgress) != len(progress) { + b.chunkProgress = make([]int64, len(progress)) + } + copy(b.chunkProgress, progress) +} + +// SetChunkState sets the 2-bit state for a specific chunk index. +func (b *BitmapTracker) SetChunkState(index int, status types.ChunkStatus) { + b.mu.Lock() + defer b.mu.Unlock() + b.setChunkState(index, status) +} + +// setChunkState sets the 2-bit state (internal, expects lock). +func (b *BitmapTracker) setChunkState(index int, status types.ChunkStatus) { + if index < 0 || index >= b.width { + return + } + + byteIndex := index / 4 + if byteIndex >= len(b.bitmap) { + return + } + bitOffset := (index % 4) * 2 + + mask := byte(3 << bitOffset) + b.bitmap[byteIndex] &= ^mask + + val := byte(status) << bitOffset + b.bitmap[byteIndex] |= val +} + +// GetChunkState gets the 2-bit state for a specific chunk index. +func (b *BitmapTracker) GetChunkState(index int) types.ChunkStatus { + b.mu.Lock() + defer b.mu.Unlock() + return b.getChunkState(index) +} + +func (b *BitmapTracker) getChunkState(index int) types.ChunkStatus { + if index < 0 || index >= b.width { + return types.ChunkPending + } + + byteIndex := index / 4 + if byteIndex >= len(b.bitmap) { + return types.ChunkPending + } + bitOffset := (index % 4) * 2 + + val := (b.bitmap[byteIndex] >> bitOffset) & 3 + return types.ChunkStatus(val) +} + +// UpdateChunkStatus updates the bitmap based on byte range and returns the incremented progress. +func (b *BitmapTracker) UpdateChunkStatus(totalSize, offset, length int64, status types.ChunkStatus) (increment int64) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.actualChunkSize == 0 || len(b.bitmap) == 0 { + return 0 + } + + if len(b.chunkProgress) != b.width { + utils.Debug("UpdateChunkStatus: Initializing ChunkProgress array (width=%d)", b.width) + b.chunkProgress = make([]int64, b.width) + } + + startIdx := int(offset / b.actualChunkSize) + endIdx := int((offset + length - 1) / b.actualChunkSize) + + if startIdx < 0 { + startIdx = 0 + } + if endIdx >= b.width { + endIdx = b.width - 1 + } + + var totalIncrement int64 + + for i := startIdx; i <= endIdx; i++ { + chunkStart := int64(i) * b.actualChunkSize + chunkEnd := chunkStart + b.actualChunkSize + if chunkEnd > totalSize { + chunkEnd = totalSize + } + + updateStart := offset + if updateStart < chunkStart { + updateStart = chunkStart + } + + updateEnd := offset + length + if updateEnd > chunkEnd { + updateEnd = chunkEnd + } + + overlap := updateEnd - updateStart + if overlap < 0 { + overlap = 0 + } + + switch status { + case types.ChunkCompleted: + inc := overlap + remainingSpace := (chunkEnd - chunkStart) - b.chunkProgress[i] + + if inc > remainingSpace { + inc = remainingSpace + } + + if inc > 0 { + b.chunkProgress[i] += inc + totalIncrement += inc + } + + if b.chunkProgress[i] >= (chunkEnd - chunkStart) { + b.chunkProgress[i] = chunkEnd - chunkStart + b.setChunkState(i, types.ChunkCompleted) + } else { + if b.getChunkState(i) != types.ChunkCompleted { + b.setChunkState(i, types.ChunkDownloading) + } + } + case types.ChunkDownloading: + current := b.getChunkState(i) + if current != types.ChunkCompleted { + b.setChunkState(i, types.ChunkDownloading) + } + } + } + + return totalIncrement +} + +// RecalculateProgress reconstructs ChunkProgress from remaining tasks and returns total verified bytes. +func (b *BitmapTracker) RecalculateProgress(totalSize int64, remainingTasks []types.Task) (totalVerified int64) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.actualChunkSize == 0 || b.width == 0 { + return 0 + } + + b.chunkProgress = make([]int64, b.width) + var total int64 + for i := 0; i < b.width; i++ { + chunkStart := int64(i) * b.actualChunkSize + chunkEnd := chunkStart + b.actualChunkSize + if chunkEnd > totalSize { + chunkEnd = totalSize + } + b.chunkProgress[i] = chunkEnd - chunkStart + total += b.chunkProgress[i] + } + + for _, task := range remainingTasks { + offset := task.Offset + length := task.Length + + startIdx := int(offset / b.actualChunkSize) + endIdx := int((offset + length - 1) / b.actualChunkSize) + + if startIdx < 0 { + startIdx = 0 + } + if endIdx >= b.width { + endIdx = b.width - 1 + } + + for i := startIdx; i <= endIdx; i++ { + chunkStart := int64(i) * b.actualChunkSize + chunkEnd := chunkStart + b.actualChunkSize + if chunkEnd > totalSize { + chunkEnd = totalSize + } + + taskStart := offset + if taskStart < chunkStart { + taskStart = chunkStart + } + + taskEnd := offset + length + if taskEnd > chunkEnd { + taskEnd = chunkEnd + } + + overlap := taskEnd - taskStart + if overlap > 0 { + b.chunkProgress[i] -= overlap + total -= overlap + } + } + } + + for i := 0; i < b.width; i++ { + chunkStart := int64(i) * b.actualChunkSize + chunkEnd := chunkStart + b.actualChunkSize + if chunkEnd > totalSize { + chunkEnd = totalSize + } + chunkSize := chunkEnd - chunkStart + + if b.chunkProgress[i] >= chunkSize { + b.chunkProgress[i] = chunkSize + b.setChunkState(i, types.ChunkCompleted) + } else if b.chunkProgress[i] > 0 { + b.setChunkState(i, types.ChunkDownloading) + } else { + b.chunkProgress[i] = 0 + b.setChunkState(i, types.ChunkPending) + } + } + return total +} + +// GetBitmapSnapshot returns a copy of bitmap metadata and optionally chunk progress. +func (b *BitmapTracker) GetBitmapSnapshot(totalSize int64, includeProgress bool) ([]byte, int, int64, int64, []int64) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.bitmap) == 0 { + return nil, 0, 0, 0, nil + } + + result := make([]byte, len(b.bitmap)) + copy(result, b.bitmap) + + var progressResult []int64 + if includeProgress { + progressResult = make([]int64, len(b.chunkProgress)) + copy(progressResult, b.chunkProgress) + } + + return result, b.width, totalSize, b.actualChunkSize, progressResult +} diff --git a/internal/progress/bytes.go b/internal/progress/bytes.go new file mode 100644 index 000000000..83786ca6a --- /dev/null +++ b/internal/progress/bytes.go @@ -0,0 +1,15 @@ +package progress + +import "sync/atomic" + +// ByteTracker handles thread-safe lock-free byte counting. +type ByteTracker struct { + Downloaded atomic.Int64 + VerifiedProgress atomic.Int64 + TotalSize atomic.Int64 // Updated dynamically if size is discovered during download +} + +// SetTotalSize initializes the total size. +func (b *ByteTracker) SetTotalSize(size int64) { + b.TotalSize.Store(size) +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 000000000..f91c670aa --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,273 @@ +package progress + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/SurgeDM/Surge/internal/types" +) + +// CfgProgress returns the *DownloadProgress associated with cfg, or +// nil if cfg.ProgressState is nil. This safely narrows the untyped State field. +func CfgProgress(cfg *types.DownloadRecord) *DownloadProgress { + if cfg == nil || cfg.ProgressState == nil { + return nil + } + dp, _ := cfg.ProgressState.(*DownloadProgress) + return dp +} + +// DownloadProgress is the facade that coordinates all trackers. +type DownloadProgress struct { + ID string + + Bytes ByteTracker + Session SessionTimer + Bitmap BitmapTracker + + ActiveWorkers atomic.Int32 + Done atomic.Bool + Paused atomic.Bool + Pausing atomic.Bool // Intermediate state: Pause requested but workers not yet exited + RateLimited atomic.Bool // Set when the downloader is backing off due to HTTP 429/rate-limit + Error atomic.Pointer[error] + + mu sync.Mutex // Protects metadata only (Mirrors, limits, strings) + cancelFunc context.CancelFunc + + destPath string + filename string + url string + mirrors []types.MirrorStatus + rateLimit int64 + rateLimitSet bool +} + +func New(id string, totalSize int64) *DownloadProgress { + dp := &DownloadProgress{ + ID: id, + } + dp.Bytes.SetTotalSize(totalSize) + // Initialize session start + dp.Session.SyncSessionStart(0) + return dp +} + +func (ps *DownloadProgress) SetDestPath(path string) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.destPath = path +} + +func (ps *DownloadProgress) GetDestPath() string { + ps.mu.Lock() + defer ps.mu.Unlock() + return ps.destPath +} + +func (ps *DownloadProgress) SetFilename(filename string) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.filename = filename +} + +func (ps *DownloadProgress) GetFilename() string { + ps.mu.Lock() + defer ps.mu.Unlock() + return ps.filename +} + +func (ps *DownloadProgress) SetURL(url string) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.url = url +} + +func (ps *DownloadProgress) GetURL() string { + ps.mu.Lock() + defer ps.mu.Unlock() + return ps.url +} + +func (ps *DownloadProgress) SetRateLimit(rate int64, explicit bool) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.rateLimit = rate + ps.rateLimitSet = explicit +} + +func (ps *DownloadProgress) GetRateLimit() (int64, bool) { + ps.mu.Lock() + defer ps.mu.Unlock() + return ps.rateLimit, ps.rateLimitSet +} + +func (ps *DownloadProgress) SetTotalSize(size int64) { + if ps.Bytes.TotalSize.Load() == size && !ps.Session.StartTime().IsZero() { + return + } + ps.Bytes.SetTotalSize(size) + ps.Session.SyncSessionStart(ps.Bytes.VerifiedProgress.Load()) +} + +func (ps *DownloadProgress) SyncSessionStart() { + ps.Session.SyncSessionStart(ps.Bytes.VerifiedProgress.Load()) +} + +func (ps *DownloadProgress) SetError(err error) { + ps.Error.Store(&err) +} + +func (ps *DownloadProgress) GetError() error { + if e := ps.Error.Load(); e != nil { + return *e + } + return nil +} + +func (ps *DownloadProgress) GetProgress() (downloaded int64, total int64, totalElapsed time.Duration, sessionElapsed time.Duration, connections int32, sessionStartBytes int64) { + downloaded = ps.Bytes.VerifiedProgress.Load() + total = ps.Bytes.TotalSize.Load() + connections = ps.ActiveWorkers.Load() + paused := ps.Paused.Load() + + sessionElapsed, totalElapsed, sessionStartBytes = ps.Session.GetElapsed(paused) + return +} + +func (ps *DownloadProgress) Pause() { + ps.Paused.Store(true) + ps.mu.Lock() + defer ps.mu.Unlock() + if ps.cancelFunc != nil { + ps.cancelFunc() + } +} + +func (ps *DownloadProgress) SetCancelFunc(cancel context.CancelFunc) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.cancelFunc = cancel +} + +func (ps *DownloadProgress) Resume() { + ps.Paused.Store(false) +} + +func (ps *DownloadProgress) IsPaused() bool { + return ps.Paused.Load() +} + +func (ps *DownloadProgress) SetPausing(pausing bool) { + ps.Pausing.Store(pausing) +} + +func (ps *DownloadProgress) IsPausing() bool { + return ps.Pausing.Load() +} + +func (ps *DownloadProgress) SetSavedElapsed(d time.Duration) { + ps.Session.SetSavedElapsed(d) +} + +func (ps *DownloadProgress) GetSavedElapsed() time.Duration { + return ps.Session.GetSavedElapsed() +} + +func (ps *DownloadProgress) FinalizeSession(downloaded int64) (time.Duration, time.Duration) { + if downloaded < 0 { + downloaded = ps.Bytes.VerifiedProgress.Load() + } + + sessionElapsed, totalElapsed := ps.Session.FinalizeSession(downloaded) + + ps.Bytes.Downloaded.Store(downloaded) + ps.Bytes.VerifiedProgress.Store(downloaded) + + return sessionElapsed, totalElapsed +} + +func (ps *DownloadProgress) SessionReset() { + ps.Bytes.Downloaded.Store(0) + ps.Bytes.VerifiedProgress.Store(0) + ps.ActiveWorkers.Store(0) + ps.Done.Store(false) + ps.Paused.Store(false) + ps.Pausing.Store(false) + ps.RateLimited.Store(false) + ps.Error.Store(nil) + + ps.Session.SessionReset() + ps.Bitmap.Reset() + + ps.mu.Lock() + defer ps.mu.Unlock() + for i := range ps.mirrors { + ps.mirrors[i].Error = false + } +} + +func (ps *DownloadProgress) FinalizePauseSession(downloaded int64) time.Duration { + _, total := ps.FinalizeSession(downloaded) + return total +} + +func (ps *DownloadProgress) SetMirrors(mirrors []types.MirrorStatus) { + ps.mu.Lock() + defer ps.mu.Unlock() + ps.mirrors = make([]types.MirrorStatus, len(mirrors)) + copy(ps.mirrors, mirrors) +} + +func (ps *DownloadProgress) GetMirrors() []types.MirrorStatus { + ps.mu.Lock() + defer ps.mu.Unlock() + if len(ps.mirrors) == 0 { + return nil + } + mirrors := make([]types.MirrorStatus, len(ps.mirrors)) + copy(mirrors, ps.mirrors) + return mirrors +} + +func (ps *DownloadProgress) InitBitmap(totalSize int64, chunkSize int64) { + ps.Bitmap.InitBitmap(totalSize, chunkSize) +} + +func (ps *DownloadProgress) RestoreBitmap(bitmap []byte, actualChunkSize int64) { + ps.Bitmap.RestoreBitmap(ps.Bytes.TotalSize.Load(), bitmap, actualChunkSize) +} + +func (ps *DownloadProgress) SetChunkProgress(progress []int64) { + ps.Bitmap.SetChunkProgress(progress) +} + +func (ps *DownloadProgress) SetChunkState(index int, status types.ChunkStatus) { + ps.Bitmap.SetChunkState(index, status) +} + +func (ps *DownloadProgress) GetChunkState(index int) types.ChunkStatus { + return ps.Bitmap.GetChunkState(index) +} + +func (ps *DownloadProgress) UpdateChunkStatus(offset, length int64, status types.ChunkStatus) { + increment := ps.Bitmap.UpdateChunkStatus(ps.Bytes.TotalSize.Load(), offset, length, status) + if increment > 0 { + ps.Bytes.VerifiedProgress.Add(increment) + } +} + +func (ps *DownloadProgress) RecalculateProgress(remainingTasks []types.Task) { + totalVerified := ps.Bitmap.RecalculateProgress(ps.Bytes.TotalSize.Load(), remainingTasks) + ps.Bytes.VerifiedProgress.Store(totalVerified) +} + +func (ps *DownloadProgress) GetBitmap() ([]byte, int, int64, int64, []int64) { + return ps.GetBitmapSnapshot(true) +} + +func (ps *DownloadProgress) GetBitmapSnapshot(includeProgress bool) ([]byte, int, int64, int64, []int64) { + return ps.Bitmap.GetBitmapSnapshot(ps.Bytes.TotalSize.Load(), includeProgress) +} diff --git a/internal/engine/types/progress_test.go b/internal/progress/progress_test.go similarity index 56% rename from internal/engine/types/progress_test.go rename to internal/progress/progress_test.go index 734305dee..ce17a9b1b 100644 --- a/internal/engine/types/progress_test.go +++ b/internal/progress/progress_test.go @@ -1,22 +1,25 @@ -package types +package progress import ( "context" + "errors" "testing" "time" + + "github.com/SurgeDM/Surge/internal/types" ) -func TestNewProgressState(t *testing.T) { - ps := NewProgressState("test-id", 1000) +func TestNew(t *testing.T) { + ps := New("test-id", 1000) if ps.ID != "test-id" { t.Errorf("ID = %s, want test-id", ps.ID) } - if ps.TotalSize != 1000 { - t.Errorf("TotalSize = %d, want 1000", ps.TotalSize) + if ps.Bytes.TotalSize.Load() != 1000 { + t.Errorf("TotalSize = %d, want 1000", ps.Bytes.TotalSize.Load()) } - if ps.Downloaded.Load() != 0 { - t.Errorf("Downloaded = %d, want 0", ps.Downloaded.Load()) + if ps.Bytes.Downloaded.Load() != 0 { + t.Errorf("Downloaded = %d, want 0", ps.Bytes.Downloaded.Load()) } if ps.ActiveWorkers.Load() != 0 { t.Errorf("ActiveWorkers = %d, want 0", ps.ActiveWorkers.Load()) @@ -33,8 +36,8 @@ func TestNewProgressState(t *testing.T) { } } -func TestProgressState_RateLimitAccessors(t *testing.T) { - ps := NewProgressState("test-id", 1000) +func TestDownloadProgress_RateLimitAccessors(t *testing.T) { + ps := New("test-id", 1000) ps.SetRateLimit(3*1024*1024, true) @@ -56,64 +59,64 @@ func TestProgressState_RateLimitAccessors(t *testing.T) { } } -func TestProgressState_SetTotalSize(t *testing.T) { - ps := NewProgressState("test", 100) - ps.Downloaded.Store(50) - ps.VerifiedProgress.Store(40) +func TestDownloadProgress_SetTotalSize(t *testing.T) { + ps := New("test", 100) + ps.Bytes.Downloaded.Store(50) + ps.Bytes.VerifiedProgress.Store(40) ps.SetTotalSize(200) - if ps.TotalSize != 200 { - t.Errorf("TotalSize = %d, want 200", ps.TotalSize) + if ps.Bytes.TotalSize.Load() != 200 { + t.Errorf("TotalSize = %d, want 200", ps.Bytes.TotalSize.Load()) } - if ps.SessionStartBytes != 40 { - t.Errorf("SessionStartBytes = %d, want 40", ps.SessionStartBytes) + if ps.Session.GetSessionStartBytesForTest() != 40 { + t.Errorf("SessionStartBytes = %d, want 40", ps.Session.GetSessionStartBytesForTest()) } } -func TestProgressState_SetTotalSize_Idempotent(t *testing.T) { - ps := NewProgressState("test-idempotent", 100) +func TestDownloadProgress_SetTotalSize_Idempotent(t *testing.T) { + ps := New("test-idempotent", 100) // Simulate a session that started 5 seconds ago originalStartTime := time.Now().Add(-5 * time.Second) - ps.StartTime = originalStartTime + ps.Session.SetStartTimeForTest(originalStartTime) // Call SetTotalSize with the SAME size ps.SetTotalSize(100) // Verify StartTime was NOT reset to Now - if !ps.StartTime.Equal(originalStartTime) { - t.Errorf("StartTime was reset despite same size: got %v, want %v", ps.StartTime, originalStartTime) + if !ps.Session.StartTime().Equal(originalStartTime) { + t.Errorf("StartTime was reset despite same size: got %v, want %v", ps.Session.StartTime(), originalStartTime) } // Call SetTotalSize with a DIFFERENT size ps.SetTotalSize(200) // Verify StartTime WAS reset (should be later than original) - if !ps.StartTime.After(originalStartTime) { - t.Errorf("StartTime was NOT reset for new size: got %v, want > %v", ps.StartTime, originalStartTime) + if !ps.Session.StartTime().After(originalStartTime) { + t.Errorf("StartTime was NOT reset for new size: got %v, want > %v", ps.Session.StartTime(), originalStartTime) } } -func TestProgressState_SyncSessionStart(t *testing.T) { - ps := NewProgressState("test", 100) - ps.Downloaded.Store(75) - ps.VerifiedProgress.Store(60) +func TestDownloadProgress_SyncSessionStart(t *testing.T) { + ps := New("test", 100) + ps.Bytes.Downloaded.Store(75) + ps.Bytes.VerifiedProgress.Store(60) beforeSync := time.Now() ps.SyncSessionStart() afterSync := time.Now() - if ps.SessionStartBytes != 60 { - t.Errorf("SessionStartBytes = %d, want 60", ps.SessionStartBytes) + if ps.Session.GetSessionStartBytesForTest() != 60 { + t.Errorf("SessionStartBytes = %d, want 60", ps.Session.GetSessionStartBytesForTest()) } - if ps.StartTime.Before(beforeSync) || ps.StartTime.After(afterSync) { + if ps.Session.StartTime().Before(beforeSync) || ps.Session.StartTime().After(afterSync) { t.Error("StartTime should be updated to current time") } } -func TestProgressState_Error(t *testing.T) { - ps := NewProgressState("test", 100) +func TestDownloadProgress_Error(t *testing.T) { + ps := New("test", 100) // Initially no error if err := ps.GetError(); err != nil { @@ -124,13 +127,13 @@ func TestProgressState_Error(t *testing.T) { testErr := context.DeadlineExceeded ps.SetError(testErr) - if err := ps.GetError(); err != testErr { + if err := ps.GetError(); !errors.Is(err, testErr) { t.Errorf("GetError = %v, want %v", err, testErr) } } -func TestProgressState_PauseResume(t *testing.T) { - ps := NewProgressState("test", 100) +func TestDownloadProgress_PauseResume(t *testing.T) { + ps := New("test", 100) // Initially not paused if ps.IsPaused() { @@ -150,8 +153,8 @@ func TestProgressState_PauseResume(t *testing.T) { } } -func TestProgressState_PauseWithCancelFunc(t *testing.T) { - ps := NewProgressState("test", 100) +func TestDownloadProgress_PauseWithCancelFunc(t *testing.T) { + ps := New("test", 100) ctx, cancel := context.WithCancel(context.Background()) ps.SetCancelFunc(cancel) @@ -174,11 +177,11 @@ func TestProgressState_PauseWithCancelFunc(t *testing.T) { } } -func TestProgressState_GetProgress(t *testing.T) { - ps := NewProgressState("test", 1000) - ps.VerifiedProgress.Store(500) +func TestDownloadProgress_GetProgress(t *testing.T) { + ps := New("test", 1000) + ps.Bytes.VerifiedProgress.Store(500) ps.ActiveWorkers.Store(4) - ps.SessionStartBytes = 100 + ps.Session.SetSessionStartBytesForTest(100) downloaded, total, totalElapsed, sessionElapsed, connections, sessionStart := ps.GetProgress() @@ -202,14 +205,14 @@ func TestProgressState_GetProgress(t *testing.T) { } } -func TestProgressState_AtomicOperations(t *testing.T) { - ps := NewProgressState("test", 1000) +func TestDownloadProgress_AtomicOperations(t *testing.T) { + ps := New("test", 1000) // Test concurrent increment done := make(chan bool, 10) for i := 0; i < 10; i++ { go func() { - ps.Downloaded.Add(100) + ps.Bytes.Downloaded.Add(100) done <- true }() } @@ -218,20 +221,20 @@ func TestProgressState_AtomicOperations(t *testing.T) { <-done } - if ps.Downloaded.Load() != 1000 { - t.Errorf("Downloaded = %d, want 1000 after 10 concurrent adds of 100", ps.Downloaded.Load()) + if ps.Bytes.Downloaded.Load() != 1000 { + t.Errorf("Downloaded = %d, want 1000 after 10 concurrent adds of 100", ps.Bytes.Downloaded.Load()) } } -func TestProgressState_ElapsedCalculation(t *testing.T) { - ps := NewProgressState("test-elapsed", 100) +func TestDownloadProgress_ElapsedCalculation(t *testing.T) { + ps := New("test-elapsed", 100) // Simulate previous session savedElapsed := 5 * time.Second ps.SetSavedElapsed(savedElapsed) // Simulate current session start 2 seconds ago - ps.StartTime = time.Now().Add(-2 * time.Second) + ps.Session.SetStartTimeForTest(time.Now().Add(-2 * time.Second)) _, _, totalElapsed, sessionElapsed, _, _ := ps.GetProgress() @@ -246,11 +249,11 @@ func TestProgressState_ElapsedCalculation(t *testing.T) { } } -func TestProgressState_GetProgress_PausedFreezesElapsed(t *testing.T) { - ps := NewProgressState("test-paused-elapsed", 100) - ps.VerifiedProgress.Store(50) +func TestDownloadProgress_GetProgress_PausedFreezesElapsed(t *testing.T) { + ps := New("test-paused-elapsed", 100) + ps.Bytes.VerifiedProgress.Store(50) ps.SetSavedElapsed(5 * time.Second) - ps.StartTime = time.Now().Add(-3 * time.Second) + ps.Session.SetStartTimeForTest(time.Now().Add(-3 * time.Second)) ps.Pause() _, _, totalElapsed, sessionElapsed, _, _ := ps.GetProgress() @@ -263,10 +266,10 @@ func TestProgressState_GetProgress_PausedFreezesElapsed(t *testing.T) { } } -func TestProgressState_FinalizeSession_AccumulatesElapsed(t *testing.T) { - ps := NewProgressState("finalize-session", 100) - ps.VerifiedProgress.Store(80) - ps.StartTime = time.Now().Add(-2 * time.Second) +func TestDownloadProgress_FinalizeSession_AccumulatesElapsed(t *testing.T) { + ps := New("finalize-session", 100) + ps.Bytes.VerifiedProgress.Store(80) + ps.Session.SetStartTimeForTest(time.Now().Add(-2 * time.Second)) sessionElapsed, totalElapsed := ps.FinalizeSession(80) @@ -279,18 +282,18 @@ func TestProgressState_FinalizeSession_AccumulatesElapsed(t *testing.T) { if got := ps.GetSavedElapsed(); got < 1500*time.Millisecond || got > 3*time.Second { t.Fatalf("GetSavedElapsed = %v, want around 2s", got) } - if ps.SessionStartBytes != 80 { - t.Fatalf("SessionStartBytes = %d, want 80", ps.SessionStartBytes) + if ps.Session.GetSessionStartBytesForTest() != 80 { + t.Fatalf("SessionStartBytes = %d, want 80", ps.Session.GetSessionStartBytesForTest()) } - if ps.VerifiedProgress.Load() != 80 { - t.Fatalf("VerifiedProgress = %d, want 80", ps.VerifiedProgress.Load()) + if ps.Bytes.VerifiedProgress.Load() != 80 { + t.Fatalf("VerifiedProgress = %d, want 80", ps.Bytes.VerifiedProgress.Load()) } } -func TestProgressState_FinalizePauseSession_UsesVerifiedWhenDownloadedUnknown(t *testing.T) { - ps := NewProgressState("finalize-pause", 100) - ps.VerifiedProgress.Store(55) - ps.StartTime = time.Now().Add(-1200 * time.Millisecond) +func TestDownloadProgress_FinalizePauseSession_UsesVerifiedWhenDownloadedUnknown(t *testing.T) { + ps := New("finalize-pause", 100) + ps.Bytes.VerifiedProgress.Store(55) + ps.Session.SetStartTimeForTest(time.Now().Add(-1200 * time.Millisecond)) ps.Pause() totalElapsed := ps.FinalizePauseSession(-1) @@ -298,40 +301,40 @@ func TestProgressState_FinalizePauseSession_UsesVerifiedWhenDownloadedUnknown(t if totalElapsed < time.Second || totalElapsed > 2500*time.Millisecond { t.Fatalf("totalElapsed = %v, want around 1.2s", totalElapsed) } - if ps.SessionStartBytes != 55 { - t.Fatalf("SessionStartBytes = %d, want 55", ps.SessionStartBytes) + if ps.Session.GetSessionStartBytesForTest() != 55 { + t.Fatalf("SessionStartBytes = %d, want 55", ps.Session.GetSessionStartBytesForTest()) } - if ps.VerifiedProgress.Load() != 55 { - t.Fatalf("VerifiedProgress = %d, want 55", ps.VerifiedProgress.Load()) + if ps.Bytes.VerifiedProgress.Load() != 55 { + t.Fatalf("VerifiedProgress = %d, want 55", ps.Bytes.VerifiedProgress.Load()) } } -func TestProgressState_SessionReset(t *testing.T) { - ps := NewProgressState("test-reset", 1000) - ps.Downloaded.Store(500) - ps.VerifiedProgress.Store(450) - ps.SessionStartBytes = 100 - ps.SavedElapsed = 10 * time.Second +func TestDownloadProgress_SessionReset(t *testing.T) { + ps := New("test-reset", 1000) + ps.Bytes.Downloaded.Store(500) + ps.Bytes.VerifiedProgress.Store(450) + ps.Session.SetSessionStartBytesForTest(100) + ps.Session.SetSavedElapsed(10 * time.Second) ps.Done.Store(true) ps.ActiveWorkers.Store(8) ps.InitBitmap(1000, 100) // Simulate some activity - ps.UpdateChunkStatus(0, 100, ChunkCompleted) + ps.UpdateChunkStatus(0, 100, types.ChunkCompleted) ps.SessionReset() - if ps.Downloaded.Load() != 0 { - t.Errorf("Downloaded = %d, want 0", ps.Downloaded.Load()) + if ps.Bytes.Downloaded.Load() != 0 { + t.Errorf("Downloaded = %d, want 0", ps.Bytes.Downloaded.Load()) } - if ps.VerifiedProgress.Load() != 0 { - t.Errorf("VerifiedProgress = %d, want 0", ps.VerifiedProgress.Load()) + if ps.Bytes.VerifiedProgress.Load() != 0 { + t.Errorf("VerifiedProgress = %d, want 0", ps.Bytes.VerifiedProgress.Load()) } - if ps.SessionStartBytes != 0 { - t.Errorf("SessionStartBytes = %d, want 0", ps.SessionStartBytes) + if ps.Session.GetSessionStartBytesForTest() != 0 { + t.Errorf("SessionStartBytes = %d, want 0", ps.Session.GetSessionStartBytesForTest()) } - if ps.SavedElapsed != 0 { - t.Errorf("SavedElapsed = %v, want 0", ps.SavedElapsed) + if ps.Session.GetSavedElapsed() != 0 { + t.Errorf("SavedElapsed = %v, want 0", ps.Session.GetSavedElapsed()) } if ps.Done.Load() { t.Error("Done should be false after reset") diff --git a/internal/progress/session.go b/internal/progress/session.go new file mode 100644 index 000000000..e929efcdf --- /dev/null +++ b/internal/progress/session.go @@ -0,0 +1,112 @@ +package progress + +import ( + "sync" + "time" +) + +// SessionTimer handles session durations and timing independently of progress updates. +type SessionTimer struct { + mu sync.Mutex + startTime time.Time + savedElapsed time.Duration + sessionStartBytes int64 +} + +func (s *SessionTimer) StartTime() time.Time { + s.mu.Lock() + defer s.mu.Unlock() + return s.startTime +} + +func (s *SessionTimer) SetStartTimeForTest(t time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.startTime = t +} + +func (s *SessionTimer) GetSessionStartBytesForTest() int64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.sessionStartBytes +} + +func (s *SessionTimer) SetSessionStartBytesForTest(b int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionStartBytes = b +} + +// SyncSessionStart synchronizes the start time and the verified bytes to start a new tracking session. +func (s *SessionTimer) SyncSessionStart(verifiedProgress int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionStartBytes = verifiedProgress + s.startTime = time.Now() +} + +// FinalizeSession closes the current session, adds its duration to savedElapsed, and starts a new session. +func (s *SessionTimer) FinalizeSession(downloaded int64) (sessionElapsed, totalElapsed time.Duration) { + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + + sessionElapsed = now.Sub(s.startTime) + if sessionElapsed < 0 { + sessionElapsed = 0 + } + s.savedElapsed += sessionElapsed + if s.savedElapsed < 0 { + s.savedElapsed = 0 + } + s.sessionStartBytes = downloaded + s.startTime = now + totalElapsed = s.savedElapsed + + return sessionElapsed, totalElapsed +} + +// GetElapsed returns the session and total elapsed times based on pause status. +func (s *SessionTimer) GetElapsed(paused bool) (sessionElapsed, totalElapsed time.Duration, sessionStartBytes int64) { + s.mu.Lock() + saved := s.savedElapsed + start := s.startTime + sessionStartBytes = s.sessionStartBytes + s.mu.Unlock() + + if paused { + sessionElapsed = 0 + totalElapsed = saved + } else { + sessionElapsed = time.Since(start) + if sessionElapsed < 0 { + sessionElapsed = 0 + } + totalElapsed = saved + sessionElapsed + } + if totalElapsed < 0 { + totalElapsed = 0 + } + return +} + +func (s *SessionTimer) SetSavedElapsed(d time.Duration) { + s.mu.Lock() + defer s.mu.Unlock() + s.savedElapsed = d +} + +func (s *SessionTimer) GetSavedElapsed() time.Duration { + s.mu.Lock() + defer s.mu.Unlock() + return s.savedElapsed +} + +// SessionReset completely clears the timer. +func (s *SessionTimer) SessionReset() { + s.mu.Lock() + defer s.mu.Unlock() + s.sessionStartBytes = 0 + s.startTime = time.Now() + s.savedElapsed = 0 +} diff --git a/internal/download/filename_test.go b/internal/scheduler/filename_test.go similarity index 99% rename from internal/download/filename_test.go rename to internal/scheduler/filename_test.go index 5255429b1..bff12de64 100644 --- a/internal/download/filename_test.go +++ b/internal/scheduler/filename_test.go @@ -1,4 +1,4 @@ -package download +package scheduler import ( "os" diff --git a/internal/download/manager.go b/internal/scheduler/manager.go similarity index 79% rename from internal/download/manager.go rename to internal/scheduler/manager.go index 40c8fbbf5..5631e2238 100644 --- a/internal/download/manager.go +++ b/internal/scheduler/manager.go @@ -1,4 +1,4 @@ -package download +package scheduler import ( "context" @@ -10,19 +10,31 @@ import ( "strings" "time" - "github.com/SurgeDM/Surge/internal/engine/concurrent" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/single" - "github.com/SurgeDM/Surge/internal/engine/types" "github.com/SurgeDM/Surge/internal/probe" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/strategy/concurrent" + "github.com/SurgeDM/Surge/internal/strategy/single" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) // safeSendProgress sends msg on ch, recovering from panics caused by sending // on a closed channel (which can happen during shutdown). -func safeSendProgress(ch chan<- any, msg any) { +func safeSendProgress(ch chan<- types.DownloadEvent, msg types.DownloadEvent, doneCh <-chan struct{}) { defer func() { _ = recover() }() - ch <- msg + if doneCh != nil { + select { + case ch <- msg: + return + default: + } + select { + case ch <- msg: + case <-doneCh: + } + } else { + ch <- msg + } } // uniqueFilePath returns a unique file path by appending (1), (2), etc. if the file exists @@ -66,13 +78,13 @@ func uniqueFilePath(path string) string { } } - // Fallback: just append a large random number or give up (original behavior essentially gave up or made ugly names) - // Here we fallback to original behavior of appending if the clean one failed 100 times - return path + // Fallback: if all 100 numbered candidates are taken, return an empty string so + // callers can detect the failure rather than silently receiving a conflicting path. + return "" } // RunDownload is the main entry point for downloads executed by the Engine pool -func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { +func RunDownload(ctx context.Context, cfg *types.DownloadRecord) error { start := time.Now() if cfg.Runtime == nil { cfg.Runtime = types.DefaultRuntimeConfig() @@ -83,16 +95,13 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { finalDestPath := filepath.Join(destPath, finalFilename) // Local mirrors slice to avoid modifying config (race condition) - mirrors := make([]string, len(cfg.Mirrors)) - copy(mirrors, cfg.Mirrors) + mirrors := append([]string(nil), cfg.Mirrors...) // Check if this is a resume (explicitly marked by TUI) - var savedState *types.DownloadState + var savedState *types.DownloadRecord if cfg.IsResume && cfg.DestPath != "" { - if cfg.SavedState != nil { - savedState = cfg.SavedState - } + savedState = cfg // Restore mirrors from state if found if savedState != nil && len(savedState.Mirrors) > 0 { @@ -122,43 +131,46 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { } utils.Debug("Destination path: %s", finalDestPath) - if cfg.State != nil { - cfg.State.SetFilename(finalFilename) - cfg.State.SetDestPath(finalDestPath) + var progState *progress.DownloadProgress + if cfg.ProgressState != nil { + progState = progress.CfgProgress(cfg) + progState.SetFilename(finalFilename) + progState.SetDestPath(finalDestPath) } currentRateLimit := func() (int64, bool) { - if cfg.State != nil { - return cfg.State.GetRateLimit() + if progState != nil { + return progState.GetRateLimit() } - return cfg.RateLimitBps, cfg.RateLimitSet + return cfg.RateLimit, cfg.RateLimitSet } // Send download started message if cfg.ProgressCh != nil { rateLimit, rateLimitSet := currentRateLimit() - safeSendProgress(cfg.ProgressCh, events.DownloadStartedMsg{ + safeSendProgress(cfg.ProgressCh, types.DownloadEvent{ + Type: types.EventStarted, DownloadID: cfg.ID, URL: cfg.URL, Filename: finalFilename, Total: cfg.TotalSize, // Relies on TotalSize from Config DestPath: finalDestPath, - State: cfg.State, + State: cfg, RateLimit: rateLimit, RateLimitSet: rateLimitSet, Workers: cfg.Runtime.Workers, MinChunkSize: cfg.Runtime.MinChunkSize, - }) + }, ctx.Done()) } // Update shared state if we have a valid size - if cfg.State != nil && cfg.TotalSize > 0 { - cfg.State.SetTotalSize(cfg.TotalSize) + if progState != nil && cfg.TotalSize > 0 { + progState.SetTotalSize(cfg.TotalSize) } effectiveTotalSize := cfg.TotalSize - if cfg.State != nil && effectiveTotalSize <= 0 { - _, stateTotal, _, _, _, _ := cfg.State.GetProgress() + if progState != nil && effectiveTotalSize <= 0 { + _, stateTotal, _, _, _, _ := progState.GetProgress() if stateTotal > 0 { effectiveTotalSize = stateTotal } @@ -197,10 +209,10 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { utils.Debug("Found %d active mirrors from %d candidates", len(activeMirrors), len(mirrors)) } - d := concurrent.NewConcurrentDownloader(cfg.ID, cfg.ProgressCh, cfg.State, cfg.Runtime) + d := concurrent.NewConcurrentDownloader(cfg.ID, cfg.ProgressCh, progState, cfg.Runtime) d.Headers = cfg.Headers // Forward custom headers from browser extension d.Limiter = cfg.Limiter - d.RateLimitBps = cfg.RateLimitBps + d.RateLimitBps = cfg.RateLimit d.RateLimitSet = cfg.RateLimitSet utils.Debug("Calling Download with mirrors: %v", mirrors) // Pass effectiveTotalSize to avoid unnecessary bootstrap if state already knows the size @@ -216,8 +228,8 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { useConcurrent = false // Trigger sequential block below // Reset progress state cleanly for single-stream restart from byte 0 - if cfg.State != nil { - cfg.State.SessionReset() + if progState != nil { + progState.SessionReset() } // Truncate the working file to zero to prevent stale tail bytes @@ -230,7 +242,7 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { if !useConcurrent { // Fallback to single-threaded downloader utils.Debug("Using single-threaded downloader") - d := single.NewSingleDownloader(cfg.ID, cfg.ProgressCh, cfg.State, cfg.Runtime) + d := single.NewSingleDownloader(cfg.ID, cfg.ProgressCh, progState, cfg.Runtime) d.Headers = cfg.Headers // Forward custom headers from browser extension d.Limiter = cfg.Limiter // Pass effectiveTotalSize here as well @@ -252,11 +264,11 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { return nil // Return nil so worker can remove it from active map } - isPaused := cfg.State != nil && cfg.State.IsPaused() + isPaused := progState != nil && progState.IsPaused() if downloadErr == nil && !isPaused { var elapsed time.Duration - if cfg.State != nil { - _, elapsed = cfg.State.FinalizeSession(effectiveTotalSize) + if progState != nil { + _, elapsed = progState.FinalizeSession(effectiveTotalSize) } else { elapsed = time.Since(start) } @@ -270,7 +282,8 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { if cfg.ProgressCh != nil { rateLimit, rateLimitSet := currentRateLimit() - safeSendProgress(cfg.ProgressCh, events.DownloadCompleteMsg{ + safeSendProgress(cfg.ProgressCh, types.DownloadEvent{ + Type: types.EventComplete, DownloadID: cfg.ID, Filename: finalFilename, Elapsed: elapsed, @@ -278,7 +291,7 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { AvgSpeed: avgSpeed, RateLimit: rateLimit, RateLimitSet: rateLimitSet, - }) + }, ctx.Done()) } } else if downloadErr != nil && !isPaused { // Verify it's not a cancellation error @@ -289,12 +302,13 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { // Send error event if cfg.ProgressCh != nil { - safeSendProgress(cfg.ProgressCh, events.DownloadErrorMsg{ + safeSendProgress(cfg.ProgressCh, types.DownloadEvent{ + Type: types.EventError, DownloadID: cfg.ID, Filename: finalFilename, DestPath: finalDestPath, Err: downloadErr, - }) + }, ctx.Done()) } } @@ -302,13 +316,13 @@ func RunDownload(ctx context.Context, cfg *types.DownloadConfig) error { } // Download is the CLI entry point (non-TUI) - convenience wrapper -func Download(ctx context.Context, url string, outPath string, progressCh chan<- any, id string) error { - cfg := types.DownloadConfig{ - URL: url, - OutputPath: outPath, - ID: id, - ProgressCh: progressCh, - State: nil, +func Download(ctx context.Context, url string, outPath string, progressCh chan<- types.DownloadEvent, id string) error { + cfg := types.DownloadRecord{ + URL: url, + OutputPath: outPath, + ID: id, + ProgressCh: progressCh, + ProgressState: nil, } // Default runtime config cfg.Runtime = types.DefaultRuntimeConfig() diff --git a/internal/download/manager_test.go b/internal/scheduler/manager_test.go similarity index 92% rename from internal/download/manager_test.go rename to internal/scheduler/manager_test.go index 09e6faaf4..34174580a 100644 --- a/internal/download/manager_test.go +++ b/internal/scheduler/manager_test.go @@ -1,4 +1,4 @@ -package download +package scheduler import ( "context" @@ -10,9 +10,9 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) func TestUniqueFilePath(t *testing.T) { @@ -157,17 +157,17 @@ func TestRunDownload_StartedEventUsesFullDestPath(t *testing.T) { } _ = f.Close() - progressCh := make(chan any, 16) + progressCh := make(chan types.DownloadEvent, 16) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - cfg := types.DownloadConfig{ + cfg := types.DownloadRecord{ URL: server.URL(), OutputPath: tmpDir, Filename: "file.bin", ID: "started-event-test", ProgressCh: progressCh, - State: types.NewProgressState("started-event-test", fileSize), + ProgressState: progress.New("started-event-test", fileSize), Runtime: &types.RuntimeConfig{}, TotalSize: fileSize, SupportsRange: false, @@ -182,10 +182,8 @@ func TestRunDownload_StartedEventUsesFullDestPath(t *testing.T) { for { select { case msg := <-progressCh: - started, ok := msg.(events.DownloadStartedMsg) - if !ok { - continue - } + started := msg + if started.DestPath != finalPath { t.Fatalf("started dest path = %q, want %q", started.DestPath, finalPath) } @@ -218,14 +216,14 @@ func TestRunDownload_ConcurrentBootstrapWithoutProbeMetadata(t *testing.T) { } _ = f.Close() - progressCh := make(chan any, 16) - cfg := types.DownloadConfig{ + progressCh := make(chan types.DownloadEvent, 16) + cfg := types.DownloadRecord{ URL: server.URL(), OutputPath: tmpDir, Filename: "file.bin", ID: "bootstrap-test", ProgressCh: progressCh, - State: types.NewProgressState("bootstrap-test", 0), + ProgressState: progress.New("bootstrap-test", 0), Runtime: &types.RuntimeConfig{}, TotalSize: 0, SupportsRange: true, @@ -234,7 +232,7 @@ func TestRunDownload_ConcurrentBootstrapWithoutProbeMetadata(t *testing.T) { if err := RunDownload(context.Background(), &cfg); err != nil { t.Fatalf("RunDownload failed: %v", err) } - _, stateTotal, _, _, _, _ := cfg.State.GetProgress() + _, stateTotal, _, _, _, _ := cfg.ProgressState.(*progress.DownloadProgress).GetProgress() if stateTotal != fileSize { t.Fatalf("state total size = %d, want %d", stateTotal, fileSize) } @@ -242,13 +240,11 @@ func TestRunDownload_ConcurrentBootstrapWithoutProbeMetadata(t *testing.T) { foundComplete := false for len(progressCh) > 0 { msg := <-progressCh - complete, ok := msg.(events.DownloadCompleteMsg) - if !ok { - continue - } - foundComplete = true - if complete.Total != fileSize { - t.Fatalf("complete total = %d, want %d", complete.Total, fileSize) + if msg.Type == types.EventComplete { + foundComplete = true + if msg.Total != fileSize { + t.Fatalf("complete total = %d, want %d", msg.Total, fileSize) + } } } if !foundComplete { @@ -278,14 +274,14 @@ func TestRunDownload_OptimisticConcurrentFallsBackToSingle(t *testing.T) { } _ = f.Close() - progressCh := make(chan any, 16) - cfg := types.DownloadConfig{ + progressCh := make(chan types.DownloadEvent, 16) + cfg := types.DownloadRecord{ URL: server.URL, OutputPath: tmpDir, Filename: "fallback.bin", ID: "optimistic-fallback-test", ProgressCh: progressCh, - State: types.NewProgressState("optimistic-fallback-test", 0), + ProgressState: progress.New("optimistic-fallback-test", 0), Runtime: &types.RuntimeConfig{}, TotalSize: 0, SupportsRange: true, @@ -302,7 +298,7 @@ func TestRunDownload_OptimisticConcurrentFallsBackToSingle(t *testing.T) { if string(got) != string(content) { t.Fatalf("downloaded content = %q, want %q", string(got), string(content)) } - _, stateTotal, _, _, _, _ := cfg.State.GetProgress() + _, stateTotal, _, _, _, _ := cfg.ProgressState.(*progress.DownloadProgress).GetProgress() if stateTotal != int64(len(content)) { t.Fatalf("state total size = %d, want %d", stateTotal, len(content)) } @@ -310,13 +306,11 @@ func TestRunDownload_OptimisticConcurrentFallsBackToSingle(t *testing.T) { foundComplete := false for len(progressCh) > 0 { msg := <-progressCh - complete, ok := msg.(events.DownloadCompleteMsg) - if !ok { - continue - } - foundComplete = true - if complete.Total != int64(len(content)) { - t.Fatalf("complete total = %d, want %d", complete.Total, len(content)) + if msg.Type == types.EventComplete { + foundComplete = true + if msg.Total != int64(len(content)) { + t.Fatalf("complete total = %d, want %d", msg.Total, len(content)) + } } } if !foundComplete { @@ -340,14 +334,14 @@ func TestRunDownload_MidTransferConcurrentFailureFallsBackToSingle(t *testing.T) _ = f.Close() } - progressCh := make(chan any, 100) - cfg := types.DownloadConfig{ + progressCh := make(chan types.DownloadEvent, 100) + cfg := types.DownloadRecord{ URL: server.URL(), OutputPath: tmpDir, Filename: "midfail.bin", ID: "mid-fail-test", ProgressCh: progressCh, - State: types.NewProgressState("mid-fail-test", 0), // Simulating unknown size + ProgressState: progress.New("mid-fail-test", 0), // Simulating unknown size Runtime: &types.RuntimeConfig{MinChunkSize: 10240}, TotalSize: 0, // Force bootstrap attempt/failure SupportsRange: true, @@ -365,7 +359,7 @@ func TestRunDownload_MidTransferConcurrentFailureFallsBackToSingle(t *testing.T) // Verification: // 1. Progress counter is correct - downloaded, _, _, _, _, _ := cfg.State.GetProgress() + downloaded, _, _, _, _, _ := cfg.ProgressState.(*progress.DownloadProgress).GetProgress() if downloaded != int64(fileSize) { t.Errorf("Progress counter = %d, want %d", downloaded, fileSize) } diff --git a/internal/download/mirror_resume_test.go b/internal/scheduler/mirror_resume_test.go similarity index 79% rename from internal/download/mirror_resume_test.go rename to internal/scheduler/mirror_resume_test.go index c1a4b5760..be6c05e81 100644 --- a/internal/download/mirror_resume_test.go +++ b/internal/scheduler/mirror_resume_test.go @@ -1,4 +1,4 @@ -package download_test +package scheduler_test import ( "context" @@ -9,11 +9,12 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/store" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" "github.com/google/uuid" ) @@ -35,10 +36,7 @@ func TestIntegration_MirrorResume(t *testing.T) { utils.ConfigureDebug(tmpDir) // Ensure clean state - state.CloseDB() - dbPath := filepath.Join(tmpDir, "surge.db") - state.Configure(dbPath) - defer state.CloseDB() + testutil.SetupStateDB(t) // 2. Setup Mock Servers (Primary + Mirror) fileSize := int64(200 * 1024 * 1024) // 200MB @@ -58,12 +56,12 @@ func TestIntegration_MirrorResume(t *testing.T) { // 3. Start Download with Mirror ctx1 := context.Background() - progressCh := make(chan any, 100) + progressCh := make(chan types.DownloadEvent, 100) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 4, } // Wire event persistence worker because pause state is persisted in processing layer. - mgr := processing.NewLifecycleManager(nil, nil) + mgr := orchestrator.NewLifecycleManager(nil, nil) var eventWG sync.WaitGroup eventWG.Add(1) go func() { @@ -75,19 +73,19 @@ func TestIntegration_MirrorResume(t *testing.T) { eventWG.Wait() }() - progState := types.NewProgressState(uuid.New().String(), fileSize) + progState := progress.New(uuid.New().String(), fileSize) filename := "mirrorfile.bin" outputPath := tmpDir destPath := filepath.Join(outputPath, filename) - cfg := types.DownloadConfig{ + cfg := types.DownloadRecord{ URL: primary.URL(), OutputPath: outputPath, Filename: filename, ID: progState.ID, ProgressCh: progressCh, - State: progState, + ProgressState: progState, Runtime: runtime, TotalSize: fileSize, SupportsRange: true, @@ -106,18 +104,18 @@ func TestIntegration_MirrorResume(t *testing.T) { // Start download and interrupt errCh := make(chan error) go func() { - errCh <- download.RunDownload(ctx1, &cfg) + errCh <- scheduler.RunDownload(ctx1, &cfg) }() // Wait until download really started so Pause() has an attached cancel func. deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { - if progState.Downloaded.Load() > 0 { + if progState.Bytes.Downloaded.Load() > 0 { break } time.Sleep(50 * time.Millisecond) } - if progState.Downloaded.Load() == 0 { + if progState.Bytes.Downloaded.Load() == 0 { t.Fatal("download did not make initial progress before pause") } @@ -135,10 +133,10 @@ func TestIntegration_MirrorResume(t *testing.T) { } // 4. Verify Mirrors Saved (event worker persists state asynchronously) - var savedState *types.DownloadState + var savedState *types.DownloadRecord deadline = time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - savedState, err = state.LoadState(primary.URL(), destPath) + savedState, err = store.LoadState(primary.URL(), destPath) if err == nil && savedState != nil && len(savedState.Mirrors) > 0 { break } @@ -171,27 +169,29 @@ func TestIntegration_MirrorResume(t *testing.T) { // 5. Resume without explicit mirrors // Create new config simulating a resumption where we don't know the mirrors initially. // Resume now receives preloaded state from the caller. - resumeState := types.NewProgressState(savedState.ID, fileSize) - resumeCfg := types.DownloadConfig{ - URL: primary.URL(), - OutputPath: outputPath, - Filename: filename, - ID: savedState.ID, - ProgressCh: progressCh, - State: resumeState, - Runtime: runtime, - TotalSize: fileSize, - SupportsRange: true, - IsResume: true, - DestPath: destPath, - SavedState: savedState, - Mirrors: []string{}, // Empty mirrors! + resumeState := progress.New(savedState.ID, fileSize) + resumeCfg := types.DownloadRecord{ + URL: primary.URL(), + OutputPath: outputPath, + Filename: filename, + ID: savedState.ID, + ProgressCh: progressCh, + ProgressState: resumeState, + Runtime: runtime, + TotalSize: fileSize, + SupportsRange: true, + IsResume: true, + DestPath: destPath, + Mirrors: savedState.Mirrors, + Tasks: savedState.Tasks, + ChunkBitmap: savedState.ChunkBitmap, + ActualChunkSize: savedState.ActualChunkSize, } // We can't easily hook into RunDownload to verify it loaded mirrors without running it. ctx2 := context.Background() go func() { - errCh <- download.RunDownload(ctx2, &resumeCfg) + errCh <- scheduler.RunDownload(ctx2, &resumeCfg) }() // Give it enough time to start and restore mirrors from saved state. diff --git a/internal/download/pool_status_test.go b/internal/scheduler/pool_status_test.go similarity index 66% rename from internal/download/pool_status_test.go rename to internal/scheduler/pool_status_test.go index a4968b3ea..ce26b3b88 100644 --- a/internal/download/pool_status_test.go +++ b/internal/scheduler/pool_status_test.go @@ -1,14 +1,15 @@ -package download +package scheduler import ( "testing" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/types" ) func TestWorkerPool_GetStatus_NonExistent(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) status := pool.GetStatus("non-existent-id") if status != nil { @@ -17,21 +18,21 @@ func TestWorkerPool_GetStatus_NonExistent(t *testing.T) { } func TestWorkerPool_GetStatus_Active(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) id := "test-id" - state := types.NewProgressState(id, 1000) - state.Downloaded.Store(500) - state.VerifiedProgress.Store(500) + state := progress.New(id, 1000) + state.Bytes.Downloaded.Store(500) + state.Bytes.VerifiedProgress.Store(500) pool.mu.Lock() pool.downloads[id] = &activeDownload{ - config: types.DownloadConfig{ - ID: id, - URL: "http://example.com/file", - Filename: "file", - State: state, + config: types.DownloadRecord{ + ID: id, + URL: "http://example.com/file", + Filename: "file", + ProgressState: state, }, } pool.mu.Unlock() @@ -60,18 +61,18 @@ func TestWorkerPool_GetStatus_Active(t *testing.T) { } func TestWorkerPool_GetStatus_Paused(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) id := "test-id" - state := types.NewProgressState(id, 1000) - state.VerifiedProgress.Store(500) - state.SessionStartBytes = 100 + state := progress.New(id, 1000) + state.Bytes.VerifiedProgress.Store(500) + state.Session.SetSessionStartBytesForTest(100) state.Pause() pool.mu.Lock() pool.downloads[id] = &activeDownload{ - config: types.DownloadConfig{ID: id, State: state}, + config: types.DownloadRecord{ID: id, ProgressState: state}, } pool.mu.Unlock() @@ -90,16 +91,16 @@ func TestWorkerPool_GetStatus_Paused(t *testing.T) { } func TestWorkerPool_GetStatus_Completed(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) id := "test-id" - state := types.NewProgressState(id, 1000) + state := progress.New(id, 1000) state.Done.Store(true) pool.mu.Lock() pool.downloads[id] = &activeDownload{ - config: types.DownloadConfig{ID: id, State: state}, + config: types.DownloadRecord{ID: id, ProgressState: state}, } pool.mu.Unlock() diff --git a/internal/download/rate_limit_pool_test.go b/internal/scheduler/rate_limit_pool_test.go similarity index 81% rename from internal/download/rate_limit_pool_test.go rename to internal/scheduler/rate_limit_pool_test.go index aa429f4ff..7258173cf 100644 --- a/internal/download/rate_limit_pool_test.go +++ b/internal/scheduler/rate_limit_pool_test.go @@ -1,29 +1,30 @@ -package download +package scheduler import ( "context" "testing" "time" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" ) // TestWorkerPool_RateLimit_QueuedUpdateHonored ensures that a per-download // rate limit set via SetDownloadRateLimit while the download is queued is // carried through to the limiter when the worker starts. func TestWorkerPool_RateLimit_QueuedUpdateHonored(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) id := "queued-rate-test" - state := types.NewProgressState(id, 0) - cfg := types.DownloadConfig{ - ID: id, - URL: "http://example.com/file.bin", - State: state, - RateLimitBps: 0, - RateLimitSet: false, + state := progress.New(id, 0) + cfg := types.DownloadRecord{ + ID: id, + URL: "http://example.com/file.bin", + ProgressState: state, + RateLimit: 0, + RateLimitSet: false, } pool.SetDefaultDownloadRateLimit(1000) @@ -42,8 +43,8 @@ func TestWorkerPool_RateLimit_QueuedUpdateHonored(t *testing.T) { if !qCfg.RateLimitSet { t.Fatal("expected RateLimitSet=true after SetDownloadRateLimit") } - if qCfg.RateLimitBps != 5*1024*1024 { - t.Fatalf("queued RateLimitBps = %d, want %d", qCfg.RateLimitBps, 5*1024*1024) + if qCfg.RateLimit != 5*1024*1024 { + t.Fatalf("queued RateLimitBps = %d, want %d", qCfg.RateLimit, 5*1024*1024) } rate, rateSet := state.GetRateLimit() if rate != 5*1024*1024 || !rateSet { @@ -59,14 +60,14 @@ func TestWorkerPool_RateLimit_QueuedUpdateHonored(t *testing.T) { // that a download with RateLimitSet=true and RateLimitBps=0 (explicit // unlimited) keeps rate=0 when the default is later raised. func TestWorkerPool_RateLimit_ExplicitUnlimitedSurvivesDefaultChange(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) id := "explicit-unlimited" - cfg := types.DownloadConfig{ + cfg := types.DownloadRecord{ ID: id, URL: "http://example.com/file.bin", - RateLimitBps: 0, + RateLimit: 0, RateLimitSet: true, } @@ -78,8 +79,8 @@ func TestWorkerPool_RateLimit_ExplicitUnlimitedSurvivesDefaultChange(t *testing. pool.ensureLimiterForConfigLocked(&testCfg) pool.mu.Unlock() - if testCfg.RateLimitBps != 0 { - t.Fatalf("Explicit unlimited should stay at 0, got %d", testCfg.RateLimitBps) + if testCfg.RateLimit != 0 { + t.Fatalf("Explicit unlimited should stay at 0, got %d", testCfg.RateLimit) } // Now raise the default @@ -89,8 +90,8 @@ func TestWorkerPool_RateLimit_ExplicitUnlimitedSurvivesDefaultChange(t *testing. qCfg, stillQueued := pool.queued[id] pool.mu.RUnlock() - if stillQueued && qCfg.RateLimitBps != 0 { - t.Errorf("Explicit unlimited was overridden by default change: got %d", qCfg.RateLimitBps) + if stillQueued && qCfg.RateLimit != 0 { + t.Errorf("Explicit unlimited was overridden by default change: got %d", qCfg.RateLimit) } pool.mu.Lock() @@ -101,24 +102,24 @@ func TestWorkerPool_RateLimit_ExplicitUnlimitedSurvivesDefaultChange(t *testing. // TestWorkerPool_RateLimit_DefaultChangeUpdatesInheritedActiveLimiter verifies // that changing the default affects already-running downloads that inherit it. func TestWorkerPool_RateLimit_DefaultChangeUpdatesInheritedActiveLimiter(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) id := "active-inherited" oldRate := int64(1) newRate := int64(10 * 1024 * 1024) - limiter := engine.NewRateLimiter(oldRate, rateLimiterBurst(oldRate)) - state := types.NewProgressState(id, 0) + limiter := transport.NewRateLimiter(oldRate, rateLimiterBurst(oldRate)) + state := progress.New(id, 0) state.SetRateLimit(oldRate, false) pool.mu.Lock() pool.downloads[id] = &activeDownload{ - config: types.DownloadConfig{ - ID: id, - URL: "http://example.com/file.bin", - State: state, - RateLimitBps: oldRate, - RateLimitSet: false, + config: types.DownloadRecord{ + ID: id, + URL: "http://example.com/file.bin", + ProgressState: state, + RateLimit: oldRate, + RateLimitSet: false, }, } pool.mu.Unlock() @@ -151,7 +152,7 @@ func TestWorkerPool_RateLimit_DefaultChangeUpdatesInheritedActiveLimiter(t *test } pool.mu.RLock() - got := pool.downloads[id].config.RateLimitBps + got := pool.downloads[id].config.RateLimit gotSet := pool.downloads[id].config.RateLimitSet pool.mu.RUnlock() @@ -170,24 +171,24 @@ func TestWorkerPool_RateLimit_DefaultChangeUpdatesInheritedActiveLimiter(t *test // TestWorkerPool_RateLimit_DefaultChangeLeavesExplicitActiveLimiter verifies // that default changes do not alter active downloads with explicit overrides. func TestWorkerPool_RateLimit_DefaultChangeLeavesExplicitActiveLimiter(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) id := "active-explicit" explicitRate := int64(1) newDefaultRate := int64(10 * 1024 * 1024) - limiter := engine.NewRateLimiter(explicitRate, rateLimiterBurst(explicitRate)) - state := types.NewProgressState(id, 0) + limiter := transport.NewRateLimiter(explicitRate, rateLimiterBurst(explicitRate)) + state := progress.New(id, 0) state.SetRateLimit(explicitRate, true) pool.mu.Lock() pool.downloads[id] = &activeDownload{ - config: types.DownloadConfig{ - ID: id, - URL: "http://example.com/file.bin", - State: state, - RateLimitBps: explicitRate, - RateLimitSet: true, + config: types.DownloadRecord{ + ID: id, + URL: "http://example.com/file.bin", + ProgressState: state, + RateLimit: explicitRate, + RateLimitSet: true, }, } pool.mu.Unlock() @@ -218,7 +219,7 @@ func TestWorkerPool_RateLimit_DefaultChangeLeavesExplicitActiveLimiter(t *testin } pool.mu.RLock() - got := pool.downloads[id].config.RateLimitBps + got := pool.downloads[id].config.RateLimit gotSet := pool.downloads[id].config.RateLimitSet pool.mu.RUnlock() @@ -245,8 +246,8 @@ func TestWorkerPool_RateLimit_DefaultChangeLeavesExplicitActiveLimiter(t *testin } func TestWorkerPool_RateLimit_UnknownDownloadDoesNotCreateLimiter(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) if ok := pool.SetDownloadRateLimit("missing", 1024); ok { t.Fatal("expected SetDownloadRateLimit to report missing download") @@ -265,8 +266,8 @@ func TestWorkerPool_RateLimit_UnknownDownloadDoesNotCreateLimiter(t *testing.T) // TestWorkerPool_RateLimit_SetGlobalHonorsWaiter verifies that // SetGlobalRateLimit wakes any goroutine blocked on the global limiter. func TestWorkerPool_RateLimit_SetGlobalHonorsWaiter(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) // 1 byte/s so WaitN blocks on a 100-byte request pool.SetGlobalRateLimit(1) @@ -299,14 +300,14 @@ func TestWorkerPool_RateLimit_SetGlobalHonorsWaiter(t *testing.T) { // TestWorkerPool_RateLimit_SetDownloadHonorsWaiter verifies that // SetDownloadRateLimit wakes any waiter blocked on the per-download limiter. func TestWorkerPool_RateLimit_SetDownloadHonorsWaiter(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) id := "dl-waiter-test" - cfg := types.DownloadConfig{ + cfg := types.DownloadRecord{ ID: id, URL: "http://example.com/file.bin", - RateLimitBps: 10000, + RateLimit: 10000, RateLimitSet: true, } pool.ensureLimiterForConfigLocked(&cfg) @@ -346,9 +347,9 @@ func TestWorkerPool_RateLimit_SetDownloadHonorsWaiter(t *testing.T) { // TestWorkerPool_RateLimit_MultiLimiterComposition verifies that the // MultiLimiter blocks until all component limiters are satisfied. func TestWorkerPool_RateLimit_MultiLimiterComposition(t *testing.T) { - global := engine.NewRateLimiter(10000, 10000) - perDl := engine.NewRateLimiter(10000, 10000) - ml := engine.NewMultiLimiter(global, perDl) + global := transport.NewRateLimiter(10000, 10000) + perDl := transport.NewRateLimiter(10000, 10000) + ml := transport.NewMultiLimiter(global, perDl) // Both limiters have 10000 tokens; requesting 20000 should block done := make(chan error, 1) diff --git a/internal/download/resume_test.go b/internal/scheduler/resume_test.go similarity index 83% rename from internal/download/resume_test.go rename to internal/scheduler/resume_test.go index 3c5963ecb..f8fc5aaa1 100644 --- a/internal/download/resume_test.go +++ b/internal/scheduler/resume_test.go @@ -1,4 +1,4 @@ -package download_test +package scheduler_test import ( "context" @@ -9,11 +9,12 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/store" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" "github.com/google/uuid" ) @@ -31,12 +32,7 @@ func TestIntegration_PauseResume(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", configDir) // Ensure clean state - state.CloseDB() - - // Force DB init - dbPath := filepath.Join(tmpDir, "surge.db") - state.Configure(dbPath) - defer state.CloseDB() + testutil.SetupStateDB(t) // 2. Setup Mock Server (500MB file) fileSize := int64(500 * 1024 * 1024) // 500MB @@ -55,10 +51,10 @@ func TestIntegration_PauseResume(t *testing.T) { // 3. Start Download and Interrupt ctx := context.Background() - progressCh := make(chan any, 100) + progressCh := make(chan types.DownloadEvent, 100) runtime := &types.RuntimeConfig{} // DB/state persistence now lives in processing event worker. - mgr := processing.NewLifecycleManager(nil, nil) + mgr := orchestrator.NewLifecycleManager(nil, nil) var eventWG sync.WaitGroup eventWG.Add(1) go func() { @@ -70,15 +66,15 @@ func TestIntegration_PauseResume(t *testing.T) { eventWG.Wait() }() - progState := types.NewProgressState(uuid.New().String(), fileSize) + progState := progress.New(uuid.New().String(), fileSize) - cfg := types.DownloadConfig{ + cfg := types.DownloadRecord{ URL: url, OutputPath: outputPath, Filename: filename, ID: progState.ID, ProgressCh: progressCh, - State: progState, + ProgressState: progState, Runtime: runtime, TotalSize: fileSize, SupportsRange: true, @@ -96,14 +92,14 @@ func TestIntegration_PauseResume(t *testing.T) { // Start download errCh := make(chan error) go func() { - errCh <- download.RunDownload(ctx, &cfg) + errCh <- scheduler.RunDownload(ctx, &cfg) }() // Wait for some progress deadline := time.Now().Add(15 * time.Second) progressed := false for time.Now().Before(deadline) { - if progState.Downloaded.Load() > 0 { + if progState.Bytes.Downloaded.Load() > 0 { progressed = true break } @@ -119,7 +115,7 @@ func TestIntegration_PauseResume(t *testing.T) { // Wait for download to return select { case err := <-errCh: - if err != nil && err != context.Canceled && !errors.Is(err, types.ErrPaused) { + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, types.ErrPaused) { t.Logf("Download returned error: %v", err) } case <-time.After(15 * time.Second): @@ -127,10 +123,10 @@ func TestIntegration_PauseResume(t *testing.T) { } // 4. Verify State is Saved (event worker persists asynchronously) - var savedState *types.DownloadState + var savedState *types.DownloadRecord deadline = time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { - savedState, err = state.LoadState(url, destPath) + savedState, err = store.LoadState(url, destPath) if err == nil && savedState != nil && savedState.Downloaded > 0 && len(savedState.Tasks) > 0 { break } @@ -171,12 +167,14 @@ func TestIntegration_PauseResume(t *testing.T) { // Update config for resume cfg.IsResume = true cfg.DestPath = destPath // Important for resume lookup - cfg.SavedState = savedState + cfg.Tasks = savedState.Tasks + cfg.ChunkBitmap = savedState.ChunkBitmap + cfg.ActualChunkSize = savedState.ActualChunkSize // Reset pause flag before resume progState.Resume() - err = download.RunDownload(resumeCtx, &cfg) + err = scheduler.RunDownload(resumeCtx, &cfg) if err != nil { t.Fatalf("Resume failed: %v", err) } @@ -187,7 +185,7 @@ func TestIntegration_PauseResume(t *testing.T) { for time.Now().Before(deadline) { _, surgeErr := os.Stat(incompletePath) finalInfo, finalErr := os.Stat(destPath) - entry, _ := state.GetDownload(cfg.ID) + entry, _ := store.GetDownload(cfg.ID) if os.IsNotExist(surgeErr) && finalErr == nil && finalInfo.Size() == fileSize && entry != nil && entry.Status == "completed" { completed = true break @@ -208,7 +206,7 @@ func TestIntegration_PauseResume(t *testing.T) { if finalInfo.Size() != fileSize { t.Errorf("Final file size = %d, want %d", finalInfo.Size(), fileSize) } - entry, _ := state.GetDownload(cfg.ID) + entry, _ := store.GetDownload(cfg.ID) if entry == nil || entry.Status != "completed" { t.Fatalf("download entry not marked completed, got %+v", entry) } diff --git a/internal/download/pool.go b/internal/scheduler/scheduler.go similarity index 64% rename from internal/download/pool.go rename to internal/scheduler/scheduler.go index d02c31639..62dc8bcf8 100644 --- a/internal/download/pool.go +++ b/internal/scheduler/scheduler.go @@ -1,4 +1,4 @@ -package download +package scheduler import ( "context" @@ -7,40 +7,44 @@ import ( "sync/atomic" "time" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) // activeDownload tracks a download that's currently running type activeDownload struct { - config types.DownloadConfig + config types.DownloadRecord cancel context.CancelFunc // running is true while the worker goroutine is executing RunDownload for this config. running atomic.Bool + // done is closed by the worker when it stops (running becomes false). + // Callers waiting for the worker to exit should select on this channel. + done chan struct{} } -// WorkerPool manages the download workers and tasks. +// Scheduler manages the download workers and tasks. // // Lock Ordering: // If a code path must acquire both the pool's main mutex (p.mu) and a limiter's internal // mutex (limiter.mu, via limiter.SetRate() or limiter.WaitN()), it MUST acquire p.mu first. // Examples: Add, SetDownloadRateLimit, SetDefaultDownloadRateLimit. // Never acquire p.mu while holding a limiter's internal mutex to prevent deadlocks. -type WorkerPool struct { +type Scheduler struct { taskChan chan string - progressCh chan<- any + progressCh chan<- types.DownloadEvent progressDone chan struct{} // closed when progressCh must no longer be sent to downloads map[string]*activeDownload // Track active downloads for pause/resume - queued map[string]types.DownloadConfig // Track queued downloads + queued map[string]types.DownloadRecord // Track queued downloads mu sync.RWMutex wg sync.WaitGroup // We use this to wait for all active downloads to pause before exiting the program maxDownloads int - globalLimiter *engine.RateLimiter - downloadLimiters map[string]*engine.RateLimiter + globalLimiter *transport.RateLimiter + downloadLimiters map[string]*transport.RateLimiter defaultDownloadRateLimitBps int64 + shutdownOnce sync.Once } var ( @@ -52,25 +56,24 @@ var ( gracefulShutdownPausePollInterval = 100 * time.Millisecond // gracefulShutdownPauseHardTimeout prevents indefinite shutdown hangs if a worker is stuck. gracefulShutdownPauseHardTimeout = 30 * time.Second + // The timeout/poll interval vars below are kept for graceful-shutdown use only. // cancelStopWaitTimeout bounds how long Cancel waits for an active worker to exit. cancelStopWaitTimeout = 3 * time.Second - // cancelStopPollInterval controls polling cadence while waiting for cancel to take effect. - cancelStopPollInterval = 10 * time.Millisecond ) -func NewWorkerPool(progressCh chan<- any, maxDownloads int) *WorkerPool { +func New(progressCh chan<- types.DownloadEvent, maxDownloads int) *Scheduler { if maxDownloads < 1 { maxDownloads = 3 // Default to 3 if invalid } - pool := &WorkerPool{ + pool := &Scheduler{ taskChan: make(chan string, 100), // We make it buffered to avoid blocking add progressCh: progressCh, progressDone: make(chan struct{}), downloads: make(map[string]*activeDownload), - queued: make(map[string]types.DownloadConfig), + queued: make(map[string]types.DownloadRecord), maxDownloads: maxDownloads, - globalLimiter: engine.NewRateLimiter(0, 0), - downloadLimiters: make(map[string]*engine.RateLimiter), + globalLimiter: transport.NewRateLimiter(0, 0), + downloadLimiters: make(map[string]*transport.RateLimiter), } for i := 0; i < maxDownloads; i++ { go pool.worker() @@ -79,33 +82,33 @@ func NewWorkerPool(progressCh chan<- any, maxDownloads int) *WorkerPool { } // syncConfigFromState syncs Filename, DestPath, and Mirrors from the associated state. -func syncConfigFromState(cfg *types.DownloadConfig) { - if cfg.State == nil { +func syncConfigFromState(cfg *types.DownloadRecord) { + if cfg.ProgressState == nil { return } - if fn := cfg.State.GetFilename(); fn != "" { + if fn := progress.CfgProgress(cfg).GetFilename(); fn != "" { cfg.Filename = fn } - if dp := cfg.State.GetDestPath(); dp != "" { + if dp := progress.CfgProgress(cfg).GetDestPath(); dp != "" { cfg.DestPath = dp } - if ms := cfg.State.GetMirrors(); len(ms) > 0 { + if ms := progress.CfgProgress(cfg).GetMirrors(); len(ms) > 0 { var urls []string for _, m := range ms { urls = append(urls, m.URL) } cfg.Mirrors = urls } - if _, totalSize, _, _, _, _ := cfg.State.GetProgress(); totalSize > 0 { + if _, totalSize, _, _, _, _ := progress.CfgProgress(cfg).GetProgress(); totalSize > 0 { cfg.TotalSize = totalSize } } // resolveDestPath resolves the destination path consistently from config, state, and output bounds. -func resolveDestPath(cfg *types.DownloadConfig) string { +func resolveDestPath(cfg *types.DownloadRecord) string { destPath := cfg.DestPath - if destPath == "" && cfg.State != nil { - destPath = cfg.State.GetDestPath() + if destPath == "" && cfg.ProgressState != nil { + destPath = progress.CfgProgress(cfg).GetDestPath() } if destPath == "" && cfg.OutputPath != "" && cfg.Filename != "" { destPath = filepath.Join(cfg.OutputPath, cfg.Filename) @@ -118,7 +121,7 @@ func resolveDestPath(cfg *types.DownloadConfig) string { // Add adds a new download task to the pool. The caller (LifecycleManager) is // responsible for emitting any lifecycle events (e.g. DownloadQueuedMsg). -func (p *WorkerPool) Add(cfg types.DownloadConfig) { +func (p *Scheduler) Add(cfg types.DownloadRecord) { if cfg.ProgressCh == nil { cfg.ProgressCh = p.progressCh } @@ -131,45 +134,45 @@ func (p *WorkerPool) Add(cfg types.DownloadConfig) { p.taskChan <- cfg.ID } -func (p *WorkerPool) ensureLimiterForConfigLocked(cfg *types.DownloadConfig) { +func (p *Scheduler) ensureLimiterForConfigLocked(cfg *types.DownloadRecord) { if cfg == nil || cfg.ID == "" { return } if p.globalLimiter == nil { - p.globalLimiter = engine.NewRateLimiter(0, 0) + p.globalLimiter = transport.NewRateLimiter(0, 0) } if p.downloadLimiters == nil { - p.downloadLimiters = make(map[string]*engine.RateLimiter) + p.downloadLimiters = make(map[string]*transport.RateLimiter) } // If state already carries an explicit rate, prefer it over cfg default. - if cfg.State != nil { - if stateRate, stateSet := cfg.State.GetRateLimit(); stateSet { - cfg.RateLimitBps = stateRate + if cfg.ProgressState != nil { + if stateRate, stateSet := progress.CfgProgress(cfg).GetRateLimit(); stateSet { + cfg.RateLimit = stateRate cfg.RateLimitSet = true } } - rate := cfg.RateLimitBps + rate := cfg.RateLimit if !cfg.RateLimitSet { rate = p.defaultDownloadRateLimitBps - cfg.RateLimitBps = rate + cfg.RateLimit = rate } - if cfg.State != nil { - cfg.State.SetRateLimit(rate, cfg.RateLimitSet) + if cfg.ProgressState != nil { + progress.CfgProgress(cfg).SetRateLimit(rate, cfg.RateLimitSet) } limiter := p.downloadLimiters[cfg.ID] if limiter == nil { - limiter = engine.NewRateLimiter(rate, rateLimiterBurst(rate)) + limiter = transport.NewRateLimiter(rate, rateLimiterBurst(rate)) p.downloadLimiters[cfg.ID] = limiter } else { limiter.SetRate(rate, rateLimiterBurst(rate)) } if cfg.Limiter == nil { - cfg.Limiter = engine.NewMultiLimiter(p.globalLimiter, limiter) + cfg.Limiter = transport.NewMultiLimiter(p.globalLimiter, limiter) } } @@ -181,7 +184,7 @@ func rateLimiterBurst(rate int64) int64 { } // HasDownload reports whether a download with the given URL is currently active or queued in the pool. -func (p *WorkerPool) HasDownload(url string) bool { +func (p *Scheduler) HasDownload(url string) bool { p.mu.RLock() for _, ad := range p.downloads { if ad.config.URL == url { @@ -201,14 +204,14 @@ func (p *WorkerPool) HasDownload(url string) bool { } // ActiveCount returns the number of currently active (downloading/pausing) downloads -func (p *WorkerPool) ActiveCount() int { +func (p *Scheduler) ActiveCount() int { p.mu.RLock() defer p.mu.RUnlock() count := 0 for _, ad := range p.downloads { // Count if not completed and not fully paused - if ad.config.State != nil && !ad.config.State.Done.Load() && !ad.config.State.IsPaused() { + if ad.config.ProgressState != nil && !progress.CfgProgress(&ad.config).Done.Load() && !progress.CfgProgress(&ad.config).IsPaused() { count++ } } @@ -218,11 +221,11 @@ func (p *WorkerPool) ActiveCount() int { } // GetAll returns all active download configs (for listing) -func (p *WorkerPool) GetAll() []types.DownloadConfig { +func (p *Scheduler) GetAll() []types.DownloadRecord { p.mu.RLock() defer p.mu.RUnlock() - configs := make([]types.DownloadConfig, 0, len(p.downloads)+len(p.queued)) + configs := make([]types.DownloadRecord, 0, len(p.downloads)+len(p.queued)) for _, ad := range p.downloads { cfg := ad.config cfg.Limiter = nil @@ -238,7 +241,7 @@ func (p *WorkerPool) GetAll() []types.DownloadConfig { // Pause pauses a specific download by ID. Returns true if found and pause initiated // (or already paused), false otherwise. Pure mechanical operation - no events emitted. -func (p *WorkerPool) Pause(downloadID string) bool { +func (p *Scheduler) Pause(downloadID string) bool { p.mu.RLock() ad, exists := p.downloads[downloadID] p.mu.RUnlock() @@ -248,20 +251,20 @@ func (p *WorkerPool) Pause(downloadID string) bool { } // Set paused flag and cancel context - if ad.config.State != nil { + if ad.config.ProgressState != nil { // Idempotency: If already paused, do nothing. - if ad.config.State.IsPaused() { + if progress.CfgProgress(&ad.config).IsPaused() { return true } // If transition is already in progress, still ensure worker context is canceled. - if ad.config.State.IsPausing() { + if progress.CfgProgress(&ad.config).IsPausing() { if ad.cancel != nil { ad.cancel() } return true } - ad.config.State.SetPausing(true) // Mark as transitioning to pause - ad.config.State.Pause() + progress.CfgProgress(&ad.config).SetPausing(true) // Mark as transitioning to pause + progress.CfgProgress(&ad.config).Pause() } // Always cancel worker context as a safety net (single downloader does not set state cancel itself). if ad.cancel != nil { @@ -274,11 +277,11 @@ func (p *WorkerPool) Pause(downloadID string) bool { } // SetGlobalRateLimit updates the global rate limiter (bytes/sec). Use 0 to disable. -func (p *WorkerPool) SetGlobalRateLimit(rate int64) { +func (p *Scheduler) SetGlobalRateLimit(rate int64) { p.mu.Lock() defer p.mu.Unlock() if p.globalLimiter == nil { - p.globalLimiter = engine.NewRateLimiter(0, 0) + p.globalLimiter = transport.NewRateLimiter(0, 0) } // All per-download MultiLimiters hold a pointer to this globalLimiter, // so updating the rate here propagates to all active downloads instantly. @@ -286,29 +289,29 @@ func (p *WorkerPool) SetGlobalRateLimit(rate int64) { } // SetDefaultDownloadRateLimit updates the default per-download rate limit (bytes/sec). -func (p *WorkerPool) SetDefaultDownloadRateLimit(rate int64) { +func (p *Scheduler) SetDefaultDownloadRateLimit(rate int64) { p.mu.Lock() defer p.mu.Unlock() p.defaultDownloadRateLimitBps = rate if p.downloadLimiters == nil { - p.downloadLimiters = make(map[string]*engine.RateLimiter) + p.downloadLimiters = make(map[string]*transport.RateLimiter) } for id, cfg := range p.queued { if cfg.RateLimitSet { continue } - cfg.RateLimitBps = rate + cfg.RateLimit = rate p.queued[id] = cfg - if cfg.State != nil { - cfg.State.SetRateLimit(rate, false) + if cfg.ProgressState != nil { + progress.CfgProgress(&cfg).SetRateLimit(rate, false) } limiter := p.downloadLimiters[id] if limiter == nil { // Note: ensureLimiterForConfigLocked guarantees all active/queued downloads have a limiter. // This nil branch is defensive and should be unreachable in practice. - p.downloadLimiters[id] = engine.NewRateLimiter(rate, rateLimiterBurst(rate)) + p.downloadLimiters[id] = transport.NewRateLimiter(rate, rateLimiterBurst(rate)) } else { limiter.SetRate(rate, rateLimiterBurst(rate)) } @@ -317,15 +320,15 @@ func (p *WorkerPool) SetDefaultDownloadRateLimit(rate int64) { if ad.config.RateLimitSet { continue } - ad.config.RateLimitBps = rate - if ad.config.State != nil { - ad.config.State.SetRateLimit(rate, false) + ad.config.RateLimit = rate + if ad.config.ProgressState != nil { + progress.CfgProgress(&ad.config).SetRateLimit(rate, false) } limiter := p.downloadLimiters[id] if limiter == nil { // Note: ensureLimiterForConfigLocked guarantees all active/queued downloads have a limiter. // This nil branch is defensive and should be unreachable in practice. - p.downloadLimiters[id] = engine.NewRateLimiter(rate, rateLimiterBurst(rate)) + p.downloadLimiters[id] = transport.NewRateLimiter(rate, rateLimiterBurst(rate)) } else { limiter.SetRate(rate, rateLimiterBurst(rate)) } @@ -333,7 +336,7 @@ func (p *WorkerPool) SetDefaultDownloadRateLimit(rate int64) { } // SetDownloadRateLimit updates a specific download's rate limit (bytes/sec). -func (p *WorkerPool) SetDownloadRateLimit(downloadID string, rate int64) bool { +func (p *Scheduler) SetDownloadRateLimit(downloadID string, rate int64) bool { if downloadID == "" { return false } @@ -343,18 +346,18 @@ func (p *WorkerPool) SetDownloadRateLimit(downloadID string, rate int64) bool { found := false if ad, ok := p.downloads[downloadID]; ok { - ad.config.RateLimitBps = rate + ad.config.RateLimit = rate ad.config.RateLimitSet = true - if ad.config.State != nil { - ad.config.State.SetRateLimit(rate, true) + if ad.config.ProgressState != nil { + progress.CfgProgress(&ad.config).SetRateLimit(rate, true) } found = true } if cfg, ok := p.queued[downloadID]; ok { - cfg.RateLimitBps = rate + cfg.RateLimit = rate cfg.RateLimitSet = true - if cfg.State != nil { - cfg.State.SetRateLimit(rate, true) + if cfg.ProgressState != nil { + progress.CfgProgress(&cfg).SetRateLimit(rate, true) } p.queued[downloadID] = cfg found = true @@ -365,11 +368,11 @@ func (p *WorkerPool) SetDownloadRateLimit(downloadID string, rate int64) bool { } if p.downloadLimiters == nil { - p.downloadLimiters = make(map[string]*engine.RateLimiter) + p.downloadLimiters = make(map[string]*transport.RateLimiter) } limiter := p.downloadLimiters[downloadID] if limiter == nil { - p.downloadLimiters[downloadID] = engine.NewRateLimiter(rate, rateLimiterBurst(rate)) + p.downloadLimiters[downloadID] = transport.NewRateLimiter(rate, rateLimiterBurst(rate)) } else { limiter.SetRate(rate, rateLimiterBurst(rate)) } @@ -377,7 +380,7 @@ func (p *WorkerPool) SetDownloadRateLimit(downloadID string, rate int64) bool { } // ClearDownloadRateLimit removes a specific download's override so it inherits the current default. -func (p *WorkerPool) ClearDownloadRateLimit(downloadID string) bool { +func (p *Scheduler) ClearDownloadRateLimit(downloadID string) bool { if downloadID == "" { return false } @@ -389,18 +392,18 @@ func (p *WorkerPool) ClearDownloadRateLimit(downloadID string) bool { found := false if ad, ok := p.downloads[downloadID]; ok { - ad.config.RateLimitBps = defaultRate + ad.config.RateLimit = defaultRate ad.config.RateLimitSet = false - if ad.config.State != nil { - ad.config.State.SetRateLimit(defaultRate, false) + if ad.config.ProgressState != nil { + progress.CfgProgress(&ad.config).SetRateLimit(defaultRate, false) } found = true } if cfg, ok := p.queued[downloadID]; ok { - cfg.RateLimitBps = defaultRate + cfg.RateLimit = defaultRate cfg.RateLimitSet = false - if cfg.State != nil { - cfg.State.SetRateLimit(defaultRate, false) + if cfg.ProgressState != nil { + progress.CfgProgress(&cfg).SetRateLimit(defaultRate, false) } p.queued[downloadID] = cfg found = true @@ -411,11 +414,11 @@ func (p *WorkerPool) ClearDownloadRateLimit(downloadID string) bool { } if p.downloadLimiters == nil { - p.downloadLimiters = make(map[string]*engine.RateLimiter) + p.downloadLimiters = make(map[string]*transport.RateLimiter) } limiter := p.downloadLimiters[downloadID] if limiter == nil { - p.downloadLimiters[downloadID] = engine.NewRateLimiter(defaultRate, rateLimiterBurst(defaultRate)) + p.downloadLimiters[downloadID] = transport.NewRateLimiter(defaultRate, rateLimiterBurst(defaultRate)) } else { limiter.SetRate(defaultRate, rateLimiterBurst(defaultRate)) } @@ -423,12 +426,12 @@ func (p *WorkerPool) ClearDownloadRateLimit(downloadID string) bool { } // PauseAll pauses all active downloads (for graceful shutdown) -func (p *WorkerPool) PauseAll() { +func (p *Scheduler) PauseAll() { p.mu.RLock() ids := make([]string, 0, len(p.downloads)) // This stores the uuids of the downloads to be paused for id, ad := range p.downloads { // Only pause downloads that are actually active (not already paused or done or pausing) - if ad != nil && ad.config.State != nil && !ad.config.State.IsPaused() && !ad.config.State.Done.Load() && !ad.config.State.IsPausing() { + if ad != nil && ad.config.ProgressState != nil && !progress.CfgProgress(&ad.config).IsPaused() && !progress.CfgProgress(&ad.config).Done.Load() && !progress.CfgProgress(&ad.config).IsPausing() { ids = append(ids, id) } } @@ -442,7 +445,7 @@ func (p *WorkerPool) PauseAll() { // Cancel cancels and removes a download by ID. Returns metadata about what was // removed so the caller (LifecycleManager) can emit events and handle cleanup. // No events are emitted by the pool itself. -func (p *WorkerPool) Cancel(downloadID string) types.CancelResult { +func (p *Scheduler) Cancel(downloadID string) types.CancelResult { p.mu.Lock() ad, activeExists := p.downloads[downloadID] qCfg, queuedExists := p.queued[downloadID] @@ -466,7 +469,7 @@ func (p *WorkerPool) Cancel(downloadID string) types.CancelResult { if activeExists && ad != nil { result.Filename = ad.config.Filename result.DestPath = resolveDestPath(&ad.config) - result.Completed = ad.config.State != nil && ad.config.State.Done.Load() + result.Completed = ad.config.ProgressState != nil && progress.CfgProgress(&ad.config).Done.Load() // Cancel the context to stop workers if ad.cancel != nil { @@ -475,14 +478,18 @@ func (p *WorkerPool) Cancel(downloadID string) types.CancelResult { // Best effort: wait for worker to exit so delete cleanup doesn't race with // downloader startup that can recreate the .surge file after removal. - deadline := time.Now().Add(cancelStopWaitTimeout) - for ad.running.Load() && time.Now().Before(deadline) { - time.Sleep(cancelStopPollInterval) + if ad.running.Load() { + deadline := time.NewTimer(cancelStopWaitTimeout) + select { + case <-ad.done: + case <-deadline.C: + } + deadline.Stop() } // Mark as done to stop polling - if ad.config.State != nil { - ad.config.State.Done.Store(true) + if ad.config.ProgressState != nil { + progress.CfgProgress(&ad.config).Done.Store(true) } } else if queuedExists { result.Filename = qCfg.Filename @@ -495,7 +502,7 @@ func (p *WorkerPool) Cancel(downloadID string) types.CancelResult { // ExtractPausedConfig atomically removes a paused download from the pool and returns // its config (with state cleared for re-enqueue) so the LifecycleManager can resume it. // Returns nil if the download is not found, not paused, or still transitioning (pausing). -func (p *WorkerPool) ExtractPausedConfig(downloadID string) *types.DownloadConfig { +func (p *Scheduler) ExtractPausedConfig(downloadID string) *types.DownloadRecord { p.mu.Lock() ad, exists := p.downloads[downloadID] if !exists || ad == nil { @@ -504,7 +511,7 @@ func (p *WorkerPool) ExtractPausedConfig(downloadID string) *types.DownloadConfi } // Cannot extract if still pausing or not actually paused - if ad.config.State == nil || !ad.config.State.IsPaused() || ad.config.State.IsPausing() { + if ad.config.ProgressState == nil || !progress.CfgProgress(&ad.config).IsPaused() || progress.CfgProgress(&ad.config).IsPausing() { p.mu.Unlock() return nil } @@ -518,8 +525,8 @@ func (p *WorkerPool) ExtractPausedConfig(downloadID string) *types.DownloadConfi p.mu.Unlock() cfg.Limiter = nil - if cfg.State != nil { - cfg.State.Resume() + if cfg.ProgressState != nil { + progress.CfgProgress(&cfg).Resume() } return &cfg } @@ -527,7 +534,7 @@ func (p *WorkerPool) ExtractPausedConfig(downloadID string) *types.DownloadConfi // UpdateURL updates the in-memory URL of a download by ID. // The caller (LifecycleManager) is responsible for persisting the change to the DB. // It fails if the download is actively downloading (not paused or errored). -func (p *WorkerPool) UpdateURL(downloadID string, newURL string) error { +func (p *Scheduler) UpdateURL(downloadID string, newURL string) error { p.mu.Lock() ad, exists := p.downloads[downloadID] _, qExists := p.queued[downloadID] @@ -538,15 +545,15 @@ func (p *WorkerPool) UpdateURL(downloadID string, newURL string) error { } if exists && ad != nil { - if ad.config.State != nil && !ad.config.State.IsPaused() { + if ad.config.ProgressState != nil && !progress.CfgProgress(&ad.config).IsPaused() { if ad.running.Load() { p.mu.Unlock() return types.ErrActiveUpdate } } ad.config.URL = newURL - if ad.config.State != nil { - ad.config.State.SetURL(newURL) + if ad.config.ProgressState != nil { + progress.CfgProgress(&ad.config).SetURL(newURL) } } p.mu.Unlock() @@ -554,7 +561,7 @@ func (p *WorkerPool) UpdateURL(downloadID string, newURL string) error { return nil } -func (p *WorkerPool) worker() { +func (p *Scheduler) worker() { for id := range p.taskChan { p.mu.RLock() cfg, stillQueued := p.queued[id] @@ -577,9 +584,10 @@ func (p *WorkerPool) worker() { ad := &activeDownload{ config: cfg, cancel: cancel, + done: make(chan struct{}), } - if ad.config.State != nil { - ad.config.State.SetCancelFunc(cancel) + if ad.config.ProgressState != nil { + progress.CfgProgress(&ad.config).SetCancelFunc(cancel) } ad.running.Store(true) @@ -601,6 +609,7 @@ func (p *WorkerPool) worker() { err := RunDownload(ctx, &localCfg) ad.running.Store(false) + close(ad.done) // unblock any Cancel caller waiting for this worker to exit // Sync back mutated fields cleanly under lock p.mu.Lock() @@ -614,15 +623,15 @@ func (p *WorkerPool) worker() { // 1. If Pause() was called: State.IsPaused() is true. We keep the task in p.downloads (so it can be resumed). // 2. If finished/error: We remove from p.downloads. - isPaused := localCfg.State != nil && localCfg.State.IsPaused() + isPaused := localCfg.ProgressState != nil && progress.CfgProgress(&localCfg).IsPaused() // Clear "Pausing" transition state now that worker has exited - if localCfg.State != nil { - localCfg.State.SetPausing(false) + if localCfg.ProgressState != nil { + progress.CfgProgress(&localCfg).SetPausing(false) } if isPaused { - utils.Debug("WorkerPool: Download %s paused cleanly", localCfg.ID) + utils.Debug("Scheduler: Download %s paused cleanly", localCfg.ID) // The concurrent downloader sends DownloadPausedMsg itself via handlePause() // (which causes RunDownload to return nil). When a single-threaded download is // paused, RunDownload returns a non-nil error, and the pool must fill the gap. @@ -632,15 +641,16 @@ func (p *WorkerPool) worker() { var rateLimitSet bool var workers int var minChunkSize int64 - if localCfg.State != nil { - downloaded = localCfg.State.Downloaded.Load() + if localCfg.ProgressState != nil { + downloaded = progress.CfgProgress(&localCfg).Bytes.Downloaded.Load() } if localCfg.Runtime != nil { workers = localCfg.Runtime.Workers minChunkSize = localCfg.Runtime.MinChunkSize } - rateLimit, rateLimitSet = localCfg.RateLimitBps, localCfg.RateLimitSet - safeSendProgress(localCfg.ProgressCh, events.DownloadPausedMsg{ + rateLimit, rateLimitSet = localCfg.RateLimit, localCfg.RateLimitSet + safeSendProgress(localCfg.ProgressCh, types.DownloadEvent{ + Type: types.EventPaused, DownloadID: localCfg.ID, Filename: localCfg.Filename, Downloaded: downloaded, @@ -648,11 +658,11 @@ func (p *WorkerPool) worker() { RateLimitSet: rateLimitSet, Workers: workers, MinChunkSize: minChunkSize, - }) + }, p.progressDone) } } else if err != nil { - if localCfg.State != nil { - localCfg.State.SetError(err) + if localCfg.ProgressState != nil { + progress.CfgProgress(&localCfg).SetError(err) } // Note: DownloadErrorMsg is already emitted by RunDownload on the same progressCh. // Clean up errored download from tracking (don't save to .surge) @@ -660,11 +670,10 @@ func (p *WorkerPool) worker() { delete(p.downloads, localCfg.ID) delete(p.downloadLimiters, localCfg.ID) p.mu.Unlock() - } else { // Only mark as done if not paused - if localCfg.State != nil { - localCfg.State.Done.Store(true) + if localCfg.ProgressState != nil { + progress.CfgProgress(&localCfg).Done.Store(true) } // Note: DownloadCompleteMsg is sent by the progress reporter when it detects Done=true @@ -680,11 +689,11 @@ func (p *WorkerPool) worker() { } // GetStatus returns the status of an active download -func (p *WorkerPool) GetStatus(id string) *types.DownloadStatus { +func (p *Scheduler) GetStatus(id string) *types.DownloadStatus { var adURL, adFilename, adDestPath string var adRateLimitBps int64 var adRateLimitSet bool - var adState *types.ProgressState + var adState *progress.DownloadProgress p.mu.RLock() ad, exists := p.downloads[id] @@ -693,9 +702,9 @@ func (p *WorkerPool) GetStatus(id string) *types.DownloadStatus { adURL = ad.config.URL adFilename = ad.config.Filename adDestPath = ad.config.DestPath - adRateLimitBps = ad.config.RateLimitBps + adRateLimitBps = ad.config.RateLimit adRateLimitSet = ad.config.RateLimitSet - adState = ad.config.State + adState = progress.CfgProgress(&ad.config) } p.mu.RUnlock() @@ -712,7 +721,7 @@ func (p *WorkerPool) GetStatus(id string) *types.DownloadStatus { Status: "queued", Downloaded: 0, TotalSize: 0, // Metadata not yet fetched - RateLimit: qCfg.RateLimitBps, + RateLimit: qCfg.RateLimit, RateLimitSet: qCfg.RateLimitSet, } } @@ -778,74 +787,78 @@ func (p *WorkerPool) GetStatus(id string) *types.DownloadStatus { } // GracefulShutdown pauses all downloads and waits for them to save state -func (p *WorkerPool) GracefulShutdown() { - p.PauseAll() - - // Discard all queued-but-not-yet-started downloads so that idle workers - // do not pick them up and begin downloading after shutdown is initiated. - // Workers already guard against this with the p.queued check at loop entry, - // so clearing the map here is sufficient; draining taskChan is belt-and-suspenders. - p.mu.Lock() - for id := range p.queued { - delete(p.queued, id) - } - p.mu.Unlock() +func (p *Scheduler) GracefulShutdown() { + p.shutdownOnce.Do(func() { + p.PauseAll() + + // Discard all queued-but-not-yet-started downloads so that idle workers + // do not pick them up and begin downloading after shutdown is initiated. + // Workers already guard against this with the p.queued check at loop entry, + // so clearing the map here is sufficient; draining taskChan is belt-and-suspenders. + p.mu.Lock() + for id := range p.queued { + delete(p.queued, id) + } + p.mu.Unlock() - // Drain taskChan to discard any configs that were already written into the - // buffered channel but not yet consumed by a worker. -drainLoop: - for { - select { - case <-p.taskChan: - p.wg.Done() - default: - break drainLoop + // Drain taskChan to discard any configs that were already written into the + // buffered channel but not yet consumed by a worker. + drainLoop: + for { + select { + case <-p.taskChan: + p.wg.Done() + default: + break drainLoop + } } - } - // Wait for any downloads in "Pausing" state to finish transitioning - // This ensures we don't exit while a database write is pending/active - ticker := time.NewTicker(gracefulShutdownPausePollInterval) - defer ticker.Stop() - start := time.Now() - warned := false + // Wait for any downloads in "Pausing" state to finish transitioning + // This ensures we don't exit while a database write is pending/active + ticker := time.NewTicker(gracefulShutdownPausePollInterval) + defer ticker.Stop() + start := time.Now() + warned := false - for { - p.mu.Lock() - stillPausing := false - for _, ad := range p.downloads { - if ad.config.State != nil && ad.config.State.IsPausing() { - // If no worker is running this download anymore, pausing is stale. - // Normalize it so shutdown can proceed. - if !ad.running.Load() { - ad.config.State.SetPausing(false) - continue + for { + p.mu.Lock() + stillPausing := false + for _, ad := range p.downloads { + if ad.config.ProgressState != nil && progress.CfgProgress(&ad.config).IsPausing() { + // If no worker is running this download anymore, pausing is stale. + // Normalize it so shutdown can proceed. + if !ad.running.Load() { + progress.CfgProgress(&ad.config).SetPausing(false) + continue + } + stillPausing = true + break } - stillPausing = true + } + p.mu.Unlock() + + if !stillPausing { break } - } - p.mu.Unlock() - if !stillPausing { - break + if !warned && time.Since(start) >= gracefulShutdownPauseSoftTimeout { + utils.Debug("GracefulShutdown: downloads still pausing after %v, continuing to wait for durable pause", gracefulShutdownPauseSoftTimeout) + warned = true + } + if time.Since(start) >= gracefulShutdownPauseHardTimeout { + utils.Debug("GracefulShutdown: forcing exit from pausing wait after hard timeout %v", gracefulShutdownPauseHardTimeout) + break + } + <-ticker.C } - if !warned && time.Since(start) >= gracefulShutdownPauseSoftTimeout { - utils.Debug("GracefulShutdown: downloads still pausing after %v, continuing to wait for durable pause", gracefulShutdownPauseSoftTimeout) - warned = true - } - if time.Since(start) >= gracefulShutdownPauseHardTimeout { - utils.Debug("GracefulShutdown: forcing exit from pausing wait after hard timeout %v", gracefulShutdownPauseHardTimeout) - break - } - <-ticker.C - } + // Signal that progressCh must no longer be sent to, so that + // safeSendProgress calls in workers can abort if the channel is full. + close(p.progressDone) - p.wg.Wait() // Blocks until all workers call Done() + p.wg.Wait() // Blocks until all workers call Done() - // Signal that progressCh must no longer be sent to, then close taskChan - // so worker goroutines exit their range loop. - close(p.progressDone) - close(p.taskChan) + // close taskChan so worker goroutines exit their range loop. + close(p.taskChan) + }) } diff --git a/internal/download/pool_test.go b/internal/scheduler/scheduler_test.go similarity index 69% rename from internal/download/pool_test.go rename to internal/scheduler/scheduler_test.go index 7a259e774..60fe7e4aa 100644 --- a/internal/download/pool_test.go +++ b/internal/scheduler/scheduler_test.go @@ -1,4 +1,4 @@ -package download +package scheduler import ( "context" @@ -9,16 +9,17 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" ) -func TestNewWorkerPool(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestNew(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) if pool == nil { - t.Fatal("Expected non-nil WorkerPool") + t.Fatal("Expected non-nil Scheduler") } if pool.taskChan == nil { @@ -38,8 +39,8 @@ func TestNewWorkerPool(t *testing.T) { } } -func TestNewWorkerPool_MaxDownloadsValidation(t *testing.T) { - ch := make(chan any, 10) +func TestNewScheduler_MaxDownloadsValidation(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) tests := []struct { name string @@ -55,7 +56,7 @@ func TestNewWorkerPool_MaxDownloadsValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - pool := NewWorkerPool(ch, tt.maxDownloads) + pool := New(ch, tt.maxDownloads) if pool.maxDownloads != tt.wantMax { t.Errorf("maxDownloads = %d, want %d", pool.maxDownloads, tt.wantMax) } @@ -63,11 +64,11 @@ func TestNewWorkerPool_MaxDownloadsValidation(t *testing.T) { } } -func TestNewWorkerPool_NilChannel(t *testing.T) { - pool := NewWorkerPool(nil, 3) +func TestNewScheduler_NilChannel(t *testing.T) { + pool := New(nil, 3) if pool == nil { - t.Fatal("Expected non-nil WorkerPool even with nil channel") + t.Fatal("Expected non-nil Scheduler even with nil channel") } if pool.progressCh != nil { @@ -75,13 +76,14 @@ func TestNewWorkerPool_NilChannel(t *testing.T) { } } -func TestWorkerPool_Add_QueuesToChannel(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Add_QueuesToChannel(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - cfg := types.DownloadConfig{ - ID: "test-id", - URL: "http://example.com/file.zip", + cfg := types.DownloadRecord{ + ID: "test-id", + URL: "http://example.com/file.zip", + ProgressState: progress.New("test-id", 1000), } // Add should not block (buffered channel) @@ -99,9 +101,9 @@ func TestWorkerPool_Add_QueuesToChannel(t *testing.T) { } } -func TestWorkerPool_Pause_NonExistentDownload(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Pause_NonExistentDownload(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // Should not panic when pausing non-existent download pool.Pause("non-existent-id") @@ -115,21 +117,21 @@ func TestWorkerPool_Pause_NonExistentDownload(t *testing.T) { } } -func TestWorkerPool_Pause_ActiveDownload(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Pause_ActiveDownload(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // Create a progress state - state := types.NewProgressState("test-id", 1000) - state.Downloaded.Store(500) - state.VerifiedProgress.Store(700) + state := progress.New("test-id", 1000) + state.Bytes.Downloaded.Store(500) + state.Bytes.VerifiedProgress.Store(700) // Manually add an active download pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: state, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: state, }, } pool.mu.Unlock() @@ -142,17 +144,17 @@ func TestWorkerPool_Pause_ActiveDownload(t *testing.T) { } } -func TestWorkerPool_Pause_NilState(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Pause_NilState(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) canceled := make(chan struct{}, 1) // Add download with nil state pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: nil, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: nil, }, cancel: func() { select { @@ -174,9 +176,9 @@ func TestWorkerPool_Pause_NilState(t *testing.T) { } } -func TestWorkerPool_PauseAll_NoDownloads(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_PauseAll_NoDownloads(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // Should not panic with no downloads pool.PauseAll() @@ -190,20 +192,20 @@ func TestWorkerPool_PauseAll_NoDownloads(t *testing.T) { } } -func TestWorkerPool_PauseAll_MultipleDownloads(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_PauseAll_MultipleDownloads(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // Add multiple active downloads - states := make([]*types.ProgressState, 3) + states := make([]*progress.DownloadProgress, 3) for i := 0; i < 3; i++ { id := string(rune('a' + i)) - states[i] = types.NewProgressState(id, 1000) + states[i] = progress.New(id, 1000) pool.mu.Lock() pool.downloads[id] = &activeDownload{ - config: types.DownloadConfig{ - ID: id, - State: states[i], + config: types.DownloadRecord{ + ID: id, + ProgressState: states[i], }, } pool.mu.Unlock() @@ -219,67 +221,67 @@ func TestWorkerPool_PauseAll_MultipleDownloads(t *testing.T) { } } -func TestWorkerPool_PauseAll_SkipsAlreadyPaused(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_PauseAll_SkipsAlreadyPaused(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // Add one paused and one active download - activeState := types.NewProgressState("active", 1000) - pausedState := types.NewProgressState("paused", 1000) + activeState := progress.New("active", 1000) + pausedState := progress.New("paused", 1000) pausedState.Paused.Store(true) pool.mu.Lock() pool.downloads["active"] = &activeDownload{ - config: types.DownloadConfig{ID: "active", State: activeState}, + config: types.DownloadRecord{ID: "active", ProgressState: activeState}, } pool.downloads["paused"] = &activeDownload{ - config: types.DownloadConfig{ID: "paused", State: pausedState}, + config: types.DownloadRecord{ID: "paused", ProgressState: pausedState}, } pool.mu.Unlock() pool.PauseAll() } -func TestWorkerPool_PauseAll_SkipsCompletedDownloads(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_PauseAll_SkipsCompletedDownloads(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // Add one completed and one active download - activeState := types.NewProgressState("active", 1000) - doneState := types.NewProgressState("done", 1000) + activeState := progress.New("active", 1000) + doneState := progress.New("done", 1000) doneState.Done.Store(true) pool.mu.Lock() pool.downloads["active"] = &activeDownload{ - config: types.DownloadConfig{ID: "active", State: activeState}, + config: types.DownloadRecord{ID: "active", ProgressState: activeState}, } pool.downloads["done"] = &activeDownload{ - config: types.DownloadConfig{ID: "done", State: doneState}, + config: types.DownloadRecord{ID: "done", ProgressState: doneState}, } pool.mu.Unlock() pool.PauseAll() } -func TestWorkerPool_Cancel_NonExistentDownload(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Cancel_NonExistentDownload(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // Should not panic pool.Cancel("non-existent-id") } -func TestWorkerPool_Cancel_RemovesFromMap(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Cancel_RemovesFromMap(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - state := types.NewProgressState("test-id", 1000) + state := progress.New("test-id", 1000) pool.mu.Lock() ad := &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: state, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: state, }, } ad.running.Store(true) @@ -319,18 +321,18 @@ func TestWorkerPool_Cancel_RemovesFromMap(t *testing.T) { } } -func TestWorkerPool_Cancel_CallsCancelFunc(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Cancel_CallsCancelFunc(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) ctx, cancel := context.WithCancel(context.Background()) - state := types.NewProgressState("test-id", 1000) + state := progress.New("test-id", 1000) pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: state, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: state, }, cancel: cancel, } @@ -358,17 +360,17 @@ func TestWorkerPool_Cancel_CallsCancelFunc(t *testing.T) { } } -func TestWorkerPool_Cancel_MarksDone(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Cancel_MarksDone(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - state := types.NewProgressState("test-id", 1000) + state := progress.New("test-id", 1000) pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: state, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: state, }, } pool.mu.Unlock() @@ -383,9 +385,9 @@ func TestWorkerPool_Cancel_MarksDone(t *testing.T) { } } -func TestWorkerPool_Cancel_DoesNotRemoveIncompleteFile(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_Cancel_DoesNotRemoveIncompleteFile(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) tmpDir := t.TempDir() destPath := filepath.Join(tmpDir, "cancel.bin") @@ -394,14 +396,14 @@ func TestWorkerPool_Cancel_DoesNotRemoveIncompleteFile(t *testing.T) { t.Fatalf("failed to create .surge file: %v", err) } - state := types.NewProgressState("test-id", 1000) - state.DestPath = destPath + state := progress.New("test-id", 1000) + state.SetDestPath(destPath) pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: state, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: state, }, } pool.mu.Unlock() @@ -416,12 +418,12 @@ func TestWorkerPool_Cancel_DoesNotRemoveIncompleteFile(t *testing.T) { } } -func TestWorkerPool_Cancel_QueuedDownload_RemovesFromQueueAndReturnsResult(t *testing.T) { - ch := make(chan any, 10) - pool := &WorkerPool{ +func TestScheduler_Cancel_QueuedDownload_RemovesFromQueueAndReturnsResult(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := &Scheduler{ progressCh: ch, downloads: make(map[string]*activeDownload), - queued: map[string]types.DownloadConfig{ + queued: map[string]types.DownloadRecord{ "queued-id": { ID: "queued-id", Filename: "queued.bin", @@ -459,20 +461,20 @@ func TestWorkerPool_Cancel_QueuedDownload_RemovesFromQueueAndReturnsResult(t *te // Resume orchestration (hot/cold path, DB hydration, event emission) was promoted to // LifecycleManager so the pool remains a pure executor with no knowledge of persistence -// or events. Tests for pool-level extraction live below; LifecycleManager integration +// or types. Tests for pool-level extraction live below; LifecycleManager integration // tests live in internal/processing/manager_test.go (see TestLifecycleManager_Cancel_NotFound). -func TestWorkerPool_GracefulShutdown_PausesAll(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_GracefulShutdown_PausesAll(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - state := types.NewProgressState("test-id", 1000) + state := progress.New("test-id", 1000) pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: state, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: state, }, } pool.mu.Unlock() @@ -510,16 +512,16 @@ func TestWorkerPool_GracefulShutdown_PausesAll(t *testing.T) { } } -func TestWorkerPool_GracefulShutdown_WaitsPastSoftTimeout(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) +func TestScheduler_GracefulShutdown_WaitsPastSoftTimeout(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) - ps := types.NewProgressState("wait-test-id", 1000) + ps := progress.New("wait-test-id", 1000) pool.mu.Lock() ad := &activeDownload{ - config: types.DownloadConfig{ - ID: "wait-test-id", - State: ps, + config: types.DownloadRecord{ + ID: "wait-test-id", + ProgressState: ps, }, } ad.running.Store(true) @@ -568,19 +570,19 @@ func TestWorkerPool_GracefulShutdown_WaitsPastSoftTimeout(t *testing.T) { } } -func TestWorkerPool_GracefulShutdown_ClearsStalePausingWithoutWorker(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) +func TestScheduler_GracefulShutdown_ClearsStalePausingWithoutWorker(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) - ps := types.NewProgressState("stale-pausing-id", 1000) + ps := progress.New("stale-pausing-id", 1000) ps.Pause() ps.SetPausing(true) pool.mu.Lock() pool.downloads["stale-pausing-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "stale-pausing-id", - State: ps, + config: types.DownloadRecord{ + ID: "stale-pausing-id", + ProgressState: ps, }, } pool.mu.Unlock() @@ -611,17 +613,17 @@ func TestWorkerPool_GracefulShutdown_ClearsStalePausingWithoutWorker(t *testing. } } -func TestWorkerPool_ConcurrentPauseCancel(t *testing.T) { - ch := make(chan any, 100) - pool := NewWorkerPool(ch, 3) +func TestScheduler_ConcurrentPauseCancel(t *testing.T) { + ch := make(chan types.DownloadEvent, 100) + pool := New(ch, 3) // Add multiple downloads for i := 0; i < 10; i++ { id := string(rune('a' + i)) - state := types.NewProgressState(id, 1000) + state := progress.New(id, 1000) pool.mu.Lock() pool.downloads[id] = &activeDownload{ - config: types.DownloadConfig{ID: id, State: state}, + config: types.DownloadRecord{ID: id, ProgressState: state}, } pool.mu.Unlock() } @@ -650,15 +652,15 @@ func TestWorkerPool_ConcurrentPauseCancel(t *testing.T) { } } -func TestWorkerPool_HasDownload(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_HasDownload(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // 1. Test Active Download activeURL := "http://example.com/active.zip" pool.mu.Lock() pool.downloads["active"] = &activeDownload{ - config: types.DownloadConfig{ + config: types.DownloadRecord{ ID: "active", URL: activeURL, }, @@ -679,9 +681,9 @@ func TestWorkerPool_HasDownload(t *testing.T) { // --- ExtractPausedConfig Tests (replaces old pool.Resume tests) --- -func TestWorkerPool_ExtractPausedConfig_NonExistent(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_ExtractPausedConfig_NonExistent(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) // Should return nil for non-existent download if cfg := pool.ExtractPausedConfig("non-existent-id"); cfg != nil { @@ -689,19 +691,19 @@ func TestWorkerPool_ExtractPausedConfig_NonExistent(t *testing.T) { } } -func TestWorkerPool_ExtractPausedConfig_WhilePausing(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_ExtractPausedConfig_WhilePausing(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - state := types.NewProgressState("test-id", 1000) + state := progress.New("test-id", 1000) state.Paused.Store(true) state.SetPausing(true) pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: state, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: state, }, } pool.mu.Unlock() @@ -720,18 +722,18 @@ func TestWorkerPool_ExtractPausedConfig_WhilePausing(t *testing.T) { } } -func TestWorkerPool_ExtractPausedConfig_NotPaused(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_ExtractPausedConfig_NotPaused(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - state := types.NewProgressState("test-id", 1000) + state := progress.New("test-id", 1000) // NOT paused pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - State: state, + config: types.DownloadRecord{ + ID: "test-id", + ProgressState: state, }, } pool.mu.Unlock() @@ -741,24 +743,24 @@ func TestWorkerPool_ExtractPausedConfig_NotPaused(t *testing.T) { } } -func TestWorkerPool_ExtractPausedConfig_Success(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_ExtractPausedConfig_Success(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - state := types.NewProgressState("test-id", 1000) + state := progress.New("test-id", 1000) state.Paused.Store(true) state.SetDestPath("/tmp/final.bin") state.SetFilename("final.bin") - staleLimiter := engine.NewMultiLimiter(pool.globalLimiter, engine.NewRateLimiter(1024, rateLimiterBurst(1024))) + staleLimiter := transport.NewMultiLimiter(pool.globalLimiter, transport.NewRateLimiter(1024, rateLimiterBurst(1024))) pool.mu.Lock() pool.downloads["test-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "test-id", - URL: "http://example.com/file.zip", - Filename: "stale.bin", - State: state, - Limiter: staleLimiter, + config: types.DownloadRecord{ + ID: "test-id", + URL: "http://example.com/file.zip", + Filename: "stale.bin", + ProgressState: state, + Limiter: staleLimiter, }, } pool.mu.Unlock() @@ -811,17 +813,17 @@ func TestWorkerPool_ExtractPausedConfig_Success(t *testing.T) { } } -func TestWorkerPool_PauseResume_Idempotency(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_PauseResume_Idempotency(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - state := types.NewProgressState("idempotent-test", 1000) + state := progress.New("idempotent-test", 1000) pool.mu.Lock() pool.downloads["idempotent-test"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "idempotent-test", - State: state, + config: types.DownloadRecord{ + ID: "idempotent-test", + ProgressState: state, }, } pool.mu.Unlock() @@ -856,20 +858,20 @@ func TestWorkerPool_PauseResume_Idempotency(t *testing.T) { } } -func TestWorkerPool_GetStatus_IncludesDestPath(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 1) +func TestScheduler_GetStatus_IncludesDestPath(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 1) destPath := "/tmp/status-dest.bin" - st := types.NewProgressState("status-id", 1024) - st.DestPath = destPath + st := progress.New("status-id", 1024) + st.SetDestPath(destPath) pool.mu.Lock() pool.downloads["status-id"] = &activeDownload{ - config: types.DownloadConfig{ - ID: "status-id", - URL: "https://example.com/file.bin", - State: st, + config: types.DownloadRecord{ + ID: "status-id", + URL: "https://example.com/file.bin", + ProgressState: st, }, } pool.mu.Unlock() @@ -883,17 +885,17 @@ func TestWorkerPool_GetStatus_IncludesDestPath(t *testing.T) { } } -func TestWorkerPool_UpdateURL(t *testing.T) { - ch := make(chan any, 10) - pool := NewWorkerPool(ch, 3) +func TestScheduler_UpdateURL(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) + pool := New(ch, 3) - activeState := types.NewProgressState("active-id", 1000) + activeState := progress.New("active-id", 1000) pool.mu.Lock() ad := &activeDownload{ - config: types.DownloadConfig{ - ID: "active-id", - URL: "http://example.com/old.zip", - State: activeState, + config: types.DownloadRecord{ + ID: "active-id", + URL: "http://example.com/old.zip", + ProgressState: activeState, }, } ad.running.Store(true) @@ -925,7 +927,7 @@ func TestWorkerPool_UpdateURL(t *testing.T) { // 3. Try updating a queued download - should fail pool.mu.Lock() - pool.queued["queued-id"] = types.DownloadConfig{ID: "queued-id"} + pool.queued["queued-id"] = types.DownloadRecord{ID: "queued-id"} pool.mu.Unlock() err = pool.UpdateURL("queued-id", "http://example.com/new.zip") @@ -935,22 +937,22 @@ func TestWorkerPool_UpdateURL(t *testing.T) { } // Note: UpdateURL DB persistence is now tested in internal/processing tests -// since LifecycleManager.UpdateURL() is responsible for calling state.UpdateURL(). +// since LifecycleManager.UpdateURL() is responsible for calling store.UpdateURL(). // --- GracefulShutdown: queued download discard tests --- -// TestWorkerPool_GracefulShutdown_ClearsQueuedMap verifies that GracefulShutdown +// TestScheduler_GracefulShutdown_ClearsQueuedMap verifies that GracefulShutdown // removes all entries from p.queued so that idle workers skip any items they // drain from taskChan after shutdown has started. -func TestWorkerPool_GracefulShutdown_ClearsQueuedMap(t *testing.T) { - ch := make(chan any, 10) +func TestScheduler_GracefulShutdown_ClearsQueuedMap(t *testing.T) { + ch := make(chan types.DownloadEvent, 10) // Use a pool with no workers so nothing auto-starts. - pool := &WorkerPool{ + pool := &Scheduler{ progressCh: ch, progressDone: make(chan struct{}), taskChan: make(chan string, 10), downloads: make(map[string]*activeDownload), - queued: make(map[string]types.DownloadConfig), + queued: make(map[string]types.DownloadRecord), maxDownloads: 0, } @@ -958,7 +960,7 @@ func TestWorkerPool_GracefulShutdown_ClearsQueuedMap(t *testing.T) { pool.mu.Lock() for i := 0; i < 5; i++ { id := fmt.Sprintf("queued-%d", i) - pool.queued[id] = types.DownloadConfig{ID: id, URL: "http://example.com/file.zip"} + pool.queued[id] = types.DownloadRecord{ID: id, URL: "http://example.com/file.zip"} } pool.mu.Unlock() @@ -983,22 +985,22 @@ func TestWorkerPool_GracefulShutdown_ClearsQueuedMap(t *testing.T) { } } -// TestWorkerPool_GracefulShutdown_DrainsTaskChan verifies that GracefulShutdown +// TestScheduler_GracefulShutdown_DrainsTaskChan verifies that GracefulShutdown // drains buffered items from taskChan so no items remain for workers to consume. -func TestWorkerPool_GracefulShutdown_DrainsTaskChan(t *testing.T) { - ch := make(chan any, 20) - pool := &WorkerPool{ +func TestScheduler_GracefulShutdown_DrainsTaskChan(t *testing.T) { + ch := make(chan types.DownloadEvent, 20) + pool := &Scheduler{ progressCh: ch, progressDone: make(chan struct{}), taskChan: make(chan string, 10), downloads: make(map[string]*activeDownload), - queued: make(map[string]types.DownloadConfig), + queued: make(map[string]types.DownloadRecord), maxDownloads: 0, } // Use Add() to ensure WaitGroup accounting is correct. for i := 0; i < 5; i++ { - pool.Add(types.DownloadConfig{ID: fmt.Sprintf("buffered-%d", i)}) + pool.Add(types.DownloadRecord{ID: fmt.Sprintf("buffered-%d", i)}) } if len(pool.taskChan) != 5 { t.Fatalf("pre-condition: expected 5 items in taskChan, got %d", len(pool.taskChan)) @@ -1026,17 +1028,17 @@ func TestWorkerPool_GracefulShutdown_DrainsTaskChan(t *testing.T) { } } -// TestWorkerPool_GracefulShutdown_WorkerSkipsQueuedAfterShutdown confirms the +// TestScheduler_GracefulShutdown_WorkerSkipsQueuedAfterShutdown confirms the // worker-side guard: a worker that pulls a cfg from taskChan after shutdown has // cleared p.queued will skip the item without starting a download. -func TestWorkerPool_GracefulShutdown_WorkerSkipsQueuedAfterShutdown(t *testing.T) { - ch := make(chan any, 50) +func TestScheduler_GracefulShutdown_WorkerSkipsQueuedAfterShutdown(t *testing.T) { + ch := make(chan types.DownloadEvent, 50) // Single worker, small taskChan. - pool := NewWorkerPool(ch, 1) + pool := New(ch, 1) // Add via public API to ensure correct internal state (WaitGroup, queued map). id := "skip-me" - pool.Add(types.DownloadConfig{ID: id}) + pool.Add(types.DownloadRecord{ID: id}) // Shutdown should drain the queue and close taskChan. done := make(chan struct{}) diff --git a/internal/core/http_client.go b/internal/service/http_client.go similarity index 99% rename from internal/core/http_client.go rename to internal/service/http_client.go index 5393f8e11..ee6b7a822 100644 --- a/internal/core/http_client.go +++ b/internal/service/http_client.go @@ -1,4 +1,4 @@ -package core +package service import ( "crypto/tls" diff --git a/internal/core/http_client_test.go b/internal/service/http_client_test.go similarity index 95% rename from internal/core/http_client_test.go rename to internal/service/http_client_test.go index d1594a06f..ea09a4d85 100644 --- a/internal/core/http_client_test.go +++ b/internal/service/http_client_test.go @@ -1,4 +1,4 @@ -package core +package service import ( "strings" diff --git a/internal/core/interface.go b/internal/service/interface.go similarity index 84% rename from internal/core/interface.go rename to internal/service/interface.go index 18ec8be42..47fe57b16 100644 --- a/internal/core/interface.go +++ b/internal/service/interface.go @@ -1,9 +1,9 @@ -package core +package service import ( "context" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) // DownloadService defines the interface for interacting with the download engine. @@ -14,13 +14,13 @@ type DownloadService interface { List() ([]types.DownloadStatus, error) // History returns completed downloads - History() ([]types.DownloadEntry, error) + History() ([]types.DownloadRecord, error) // Add queues a new download. - Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) + Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64) (string, error) // AddWithID queues a new download with a caller-provided ID. - AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) + AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) // Pause pauses an active download. Pause(id string) error @@ -40,13 +40,13 @@ type DownloadService interface { // Purge cancels and removes a download, and deletes its files from disk. Purge(id string) error - // StreamEvents returns a channel that receives real-time download events. + // StreamEvents returns a channel that receives real-time download types. // For local mode, this is a direct channel. // For remote mode, this is sourced from SSE. - StreamEvents(ctx context.Context) (<-chan interface{}, func(), error) + StreamEvents(ctx context.Context) (<-chan types.DownloadEvent, func(), error) // Publish emits an event into the service's event stream. - Publish(msg interface{}) error + Publish(msg types.DownloadEvent) error // GetStatus returns a status for a single download by id. GetStatus(id string) (*types.DownloadStatus, error) diff --git a/internal/service/local_service.go b/internal/service/local_service.go new file mode 100644 index 000000000..a3aca5ffc --- /dev/null +++ b/internal/service/local_service.go @@ -0,0 +1,414 @@ +package service + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/SurgeDM/Surge/internal/config" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" + "github.com/SurgeDM/Surge/internal/utils" +) + +func completedSpeedBps(entry types.DownloadRecord) float64 { + if entry.Status != "completed" { + return 0 + } + if entry.AvgSpeed > 0 { + return entry.AvgSpeed + } + if entry.TimeTaken > 0 { + return float64(entry.TotalSize) * 1000 / float64(entry.TimeTaken) + } + return 0 +} + +type LocalDownloadService struct { + lifecycle *orchestrator.LifecycleManager +} + +func NewLocalDownloadService(lifecycle *orchestrator.LifecycleManager) *LocalDownloadService { + return &LocalDownloadService{lifecycle: lifecycle} +} + +func (s *LocalDownloadService) ReloadSettings() error { + if s.lifecycle != nil { + settings, err := config.LoadSettings() + if err != nil { + return err + } + s.lifecycle.ApplySettings(settings) + } + return nil +} + +func (s *LocalDownloadService) StreamEvents(ctx context.Context) (<-chan types.DownloadEvent, func(), error) { + if s.lifecycle == nil || s.lifecycle.GetEventBus() == nil { + return nil, nil, fmt.Errorf("event bus not initialized") + } + ch, cleanup := s.lifecycle.GetEventBus().Subscribe() + return ch, cleanup, nil +} + +func (s *LocalDownloadService) Publish(msg types.DownloadEvent) error { + if s.lifecycle != nil && s.lifecycle.GetEventBus() != nil { + return s.lifecycle.GetEventBus().Publish(msg) + } + return fmt.Errorf("event bus not initialized") +} + +func (s *LocalDownloadService) Shutdown() error { + if s.lifecycle != nil { + s.lifecycle.Shutdown() + } + return nil +} + +func (s *LocalDownloadService) Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64) (string, error) { + req := &orchestrator.DownloadRequest{ + URL: url, + Path: path, + Filename: filename, + Mirrors: mirrors, + Headers: headers, + IsExplicitCategory: isExplicitCategory, + Workers: workers, + MinChunkSize: minChunkSize, + } + id, _, err := s.lifecycle.Enqueue(context.Background(), req) + return id, err +} + +func (s *LocalDownloadService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) { + req := &orchestrator.DownloadRequest{ + URL: url, + Path: path, + Filename: filename, + Mirrors: mirrors, + Headers: headers, + IsExplicitCategory: false, + Workers: workers, + MinChunkSize: minChunkSize, + } + newID, _, err := s.lifecycle.EnqueueWithID(context.Background(), req, id) + return newID, err +} + +func (s *LocalDownloadService) Pause(id string) error { + return s.lifecycle.Pause(id) +} + +func (s *LocalDownloadService) Resume(id string) error { + return s.lifecycle.Resume(id) +} + +func (s *LocalDownloadService) ResumeBatch(ids []string) []error { + return s.lifecycle.ResumeBatch(ids) +} + +func (s *LocalDownloadService) UpdateURL(id string, newURL string) error { + return s.lifecycle.UpdateURL(id, newURL) +} + +func (s *LocalDownloadService) Delete(id string) error { + return s.lifecycle.Cancel(id) +} + +func (s *LocalDownloadService) Purge(id string) error { + destPath := "" + status, err := s.GetStatus(id) + if err == nil && status != nil { + destPath = filepath.Clean(status.DestPath) + } else { + history, err := s.History() + if err == nil { + for _, entry := range history { + if entry.ID == id { + destPath = filepath.Clean(entry.DestPath) + break + } + } + } + } + if err := s.Delete(id); err != nil { + return err + } + if destPath != "" && destPath != "." { + var errs []string + if err := utils.RemoveFile(destPath); err != nil && !os.IsNotExist(err) { + errs = append(errs, err.Error()) + } + if err := utils.RemoveFile(destPath + types.IncompleteSuffix); err != nil && !os.IsNotExist(err) { + errs = append(errs, err.Error()) + } + if len(errs) > 0 { + return fmt.Errorf("failed to delete files: %s", strings.Join(errs, ", ")) + } + } + return nil +} + +func (s *LocalDownloadService) GetStatus(id string) (*types.DownloadStatus, error) { + if id == "" { + return nil, fmt.Errorf("missing id") + } + if s.lifecycle != nil && s.lifecycle.GetScheduler() != nil { + if status := s.lifecycle.GetScheduler().GetStatus(id); status != nil { + return status, nil + } + } + entry, err := store.GetDownload(id) + if err == nil && entry != nil { + var progress float64 + if entry.TotalSize > 0 { + progress = float64(entry.Downloaded) * 100 / float64(entry.TotalSize) + } else if entry.Status == "completed" { + progress = 100.0 + } + status := types.DownloadStatus{ + ID: entry.ID, + URL: entry.URL, + Filename: entry.Filename, + DestPath: entry.DestPath, + TotalSize: entry.TotalSize, + Downloaded: entry.Downloaded, + Progress: progress, + Speed: completedSpeedBps(*entry), + Status: entry.Status, + TimeTaken: entry.TimeTaken, + AvgSpeed: entry.AvgSpeed, + RateLimit: entry.RateLimit, + RateLimitSet: entry.RateLimitSet, + } + return &status, nil + } + return nil, types.ErrNotFound +} + +func (s *LocalDownloadService) History() ([]types.DownloadRecord, error) { + return store.LoadCompletedDownloads() +} +func (s *LocalDownloadService) ClearCompleted() (int64, error) { + return store.RemoveCompletedDownloads() +} +func (s *LocalDownloadService) ClearFailed() (int64, error) { + return store.RemoveFailedDownloads() +} + +func (s *LocalDownloadService) SetRateLimit(id string, rate int64) error { + if rate < 0 { + return fmt.Errorf("rate limit must be non-negative") + } + if s.lifecycle == nil || s.lifecycle.GetScheduler() == nil { + return types.ErrPoolNotInit + } + + entry, err := store.GetDownload(id) + if err != nil && !errors.Is(err, types.ErrNotFound) { + return err + } + + poolStatus := s.lifecycle.GetScheduler().GetStatus(id) + if poolStatus == nil && (entry == nil || entry.Status == "completed") { + return fmt.Errorf("%w: %s", types.ErrNotFound, id) + } + + err = store.UpdateRateLimit(id, rate) + if err != nil && !errors.Is(err, types.ErrNotFound) { + return err + } + + foundInPool := s.lifecycle.GetScheduler().SetDownloadRateLimit(id, rate) + if err != nil && !foundInPool { + return fmt.Errorf("%w: %s", types.ErrNotFound, id) + } + return nil +} + +func (s *LocalDownloadService) ClearRateLimit(id string) error { + if s.lifecycle == nil || s.lifecycle.GetScheduler() == nil { + return types.ErrPoolNotInit + } + + entry, err := store.GetDownload(id) + if err != nil && !errors.Is(err, types.ErrNotFound) { + return err + } + + poolStatus := s.lifecycle.GetScheduler().GetStatus(id) + if poolStatus == nil && (entry == nil || entry.Status == "completed") { + return fmt.Errorf("%w: %s", types.ErrNotFound, id) + } + + err = store.ClearRateLimit(id) + if err != nil && !errors.Is(err, types.ErrNotFound) { + return err + } + + foundInPool := s.lifecycle.GetScheduler().ClearDownloadRateLimit(id) + if err != nil && !foundInPool { + return fmt.Errorf("%w: %s", types.ErrNotFound, id) + } + return nil +} + +func (s *LocalDownloadService) SetGlobalRateLimit(rate int64) error { + if rate < 0 { + return fmt.Errorf("rate limit must be non-negative") + } + if s.lifecycle == nil || s.lifecycle.GetScheduler() == nil { + return types.ErrPoolNotInit + } + + settings := s.lifecycle.GetSettings() + if settings == nil { + return fmt.Errorf("settings not found") + } + + if settings.Network.GlobalRateLimit == nil { + settings.Network.GlobalRateLimit = config.DefaultSettings().Network.GlobalRateLimit + } + oldValue := settings.Network.GlobalRateLimit.Value + settings.Network.GlobalRateLimit.Value = utils.FormatRateLimit(rate) + if err := config.SaveSettings(settings); err != nil { + settings.Network.GlobalRateLimit.Value = oldValue + return err + } + + s.lifecycle.GetScheduler().SetGlobalRateLimit(rate) + return nil +} + +func (s *LocalDownloadService) SetDefaultRateLimit(rate int64) error { + if rate < 0 { + return fmt.Errorf("rate limit must be non-negative") + } + if s.lifecycle == nil || s.lifecycle.GetScheduler() == nil { + return types.ErrPoolNotInit + } + + settings := s.lifecycle.GetSettings() + if settings == nil { + return fmt.Errorf("settings not found") + } + + if settings.Network.DefaultDownloadRateLimit == nil { + settings.Network.DefaultDownloadRateLimit = config.DefaultSettings().Network.DefaultDownloadRateLimit + } + oldValue := settings.Network.DefaultDownloadRateLimit.Value + settings.Network.DefaultDownloadRateLimit.Value = utils.FormatRateLimit(rate) + if err := config.SaveSettings(settings); err != nil { + settings.Network.DefaultDownloadRateLimit.Value = oldValue + return err + } + + s.lifecycle.GetScheduler().SetDefaultDownloadRateLimit(rate) + + if configs := s.lifecycle.GetScheduler().GetAll(); configs != nil { + var dbErrs []string + for _, cfg := range configs { + if !cfg.RateLimitSet { + if err := store.UpdateDefaultRateLimit(cfg.ID, rate); err != nil { + dbErrs = append(dbErrs, fmt.Sprintf("%s: %v", cfg.ID, err)) + } + } + } + if len(dbErrs) > 0 { + return fmt.Errorf("failed to update default rate limit in DB for some downloads: %s", strings.Join(dbErrs, "; ")) + } + } + return nil +} + +func (s *LocalDownloadService) List() ([]types.DownloadStatus, error) { + var statuses []types.DownloadStatus + if s.lifecycle != nil && s.lifecycle.GetScheduler() != nil { + activeConfigs := s.lifecycle.GetScheduler().GetAll() + for _, cfg := range activeConfigs { + statusStr := "downloading" + if st := s.lifecycle.GetScheduler().GetStatus(cfg.ID); st != nil { + statusStr = st.Status + } + status := types.DownloadStatus{ + ID: cfg.ID, + URL: cfg.URL, + Filename: cfg.Filename, + Status: statusStr, + RateLimit: cfg.RateLimit, + RateLimitSet: cfg.RateLimitSet, + } + if cfg.ProgressState != nil { + cp := progress.CfgProgress(&cfg) + downloaded, totalSize, _, sessionElapsed, connections, sessionStart := cp.GetProgress() + status.TotalSize = totalSize + status.Downloaded = downloaded + if dp := cp.GetDestPath(); dp != "" { + status.DestPath = dp + } + if status.TotalSize > 0 { + status.Progress = float64(status.Downloaded) * 100 / float64(status.TotalSize) + } + status.Connections = int(connections) + if cp.IsPausing() { + status.Status = "pausing" + } else if cp.IsPaused() { + status.Status = "paused" + } else if cp.Done.Load() { + status.Status = "completed" + } + if status.Status == "downloading" { + sessionDownloaded := downloaded - sessionStart + if sessionElapsed.Seconds() > 0 && sessionDownloaded > 0 { + status.Speed = float64(sessionDownloaded) / sessionElapsed.Seconds() + remaining := status.TotalSize - status.Downloaded + if remaining > 0 && status.Speed > 0 { + status.ETA = int64(float64(remaining) / status.Speed) + } + } + } + } + statuses = append(statuses, status) + } + } + dbDownloads, err := store.ListAllDownloads() + if err == nil { + existingIDs := make(map[string]bool) + for _, s := range statuses { + existingIDs[s.ID] = true + } + for _, d := range dbDownloads { + if existingIDs[d.ID] { + continue + } + var progress float64 + if d.TotalSize > 0 { + progress = float64(d.Downloaded) * 100 / float64(d.TotalSize) + } else if d.Status == "completed" { + progress = 100.0 + } + statuses = append(statuses, types.DownloadStatus{ + ID: d.ID, + URL: d.URL, + Filename: d.Filename, + DestPath: d.DestPath, + Status: d.Status, + TotalSize: d.TotalSize, + Downloaded: d.Downloaded, + Progress: progress, + Speed: completedSpeedBps(d), + Connections: 0, + TimeTaken: d.TimeTaken, + AvgSpeed: d.AvgSpeed, + RateLimit: d.RateLimit, + RateLimitSet: d.RateLimitSet, + }) + } + } + return statuses, nil +} diff --git a/internal/service/local_service_test.go b/internal/service/local_service_test.go new file mode 100644 index 000000000..49341d822 --- /dev/null +++ b/internal/service/local_service_test.go @@ -0,0 +1,306 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" +) + +func setupTestService(t *testing.T) (*LocalDownloadService, *httptest.Server, string) { + testutil.SetupStateDB(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1024") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(make([]byte, 1024)) + })) + + progressCh := make(chan types.DownloadEvent, 10) + pool := scheduler.New(progressCh, 1) + eb := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(pool, eb) + + // Ensure config directory exists for settings tests + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + + svc := NewLocalDownloadService(mgr) + + // Mock event worker to handle EventRemoved + ch, cleanup := eb.Subscribe() + go func() { + for e := range ch { + if e.Type == types.EventRemoved { + _ = store.DeleteState(e.DownloadID) + } + } + }() + t.Cleanup(cleanup) + + return svc, ts, tmpDir +} + +func TestLocalDownloadService_AddWithID_UsesProvidedID(t *testing.T) { + svc, ts, tmpDir := setupTestService(t) + defer ts.Close() + t.Cleanup(func() { _ = svc.Shutdown() }) + + customID := "test-id-123" + id, err := svc.AddWithID(ts.URL, tmpDir, "test.txt", nil, nil, customID, 1, 0) + + if err != nil { + t.Fatalf("AddWithID failed: %v", err) + } + if id != customID { + t.Errorf("expected ID %s, got %s", customID, id) + } + + status, err := svc.GetStatus(customID) + if err != nil { + t.Fatalf("GetStatus failed: %v", err) + } + if status.ID != customID { + t.Errorf("expected status ID %s, got %s", customID, status.ID) + } +} + +func TestLocalDownloadService_StreamEvents(t *testing.T) { + svc, ts, tmpDir := setupTestService(t) + defer ts.Close() + + ch, cleanup, err := svc.StreamEvents(context.Background()) + if err != nil { + t.Fatalf("StreamEvents failed: %v", err) + } + defer cleanup() + + // Add a download to generate an event + _, _ = svc.Add(ts.URL, tmpDir, "event.txt", nil, nil, false, 1, 0) + + select { + case <-ch: + // Received an event + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for event") + } + + // Shutting down should close the channel + if err := svc.Shutdown(); err != nil { + t.Errorf("Shutdown failed: %v", err) + } + + // Drain the channel until it is closed + closed := false + timeout := time.After(2 * time.Second) + for !closed { + select { + case _, ok := <-ch: + if !ok { + closed = true + } + case <-timeout: + t.Fatal("timed out waiting for channel to close") + } + } +} + +func TestLocalDownloadService_RateLimits(t *testing.T) { + svc, ts, tmpDir := setupTestService(t) + defer ts.Close() + t.Cleanup(func() { _ = svc.Shutdown() }) + + err := svc.SetRateLimit("invalid", 100) + if err == nil { + t.Error("expected error setting rate limit on unknown ID") + } + + err = svc.SetGlobalRateLimit(-1) + if err == nil { + t.Error("expected error for negative global rate limit") + } + + err = svc.SetDefaultRateLimit(-1) + if err == nil { + t.Error("expected error for negative default rate limit") + } + + // Test Global Rate Limit (which saves settings) + err = svc.SetGlobalRateLimit(5000) + if err != nil { + t.Errorf("SetGlobalRateLimit failed: %v", err) + } + + settings := svc.lifecycle.GetSettings() + if settings.Network.GlobalRateLimit.Value != "4.9 KiB/s" && settings.Network.GlobalRateLimit.Value != "5000" { + t.Errorf("expected GlobalRateLimit to be set to 4.9 KiB/s, got %s", settings.Network.GlobalRateLimit.Value) + } + + // Test Default Rate Limit + err = svc.SetDefaultRateLimit(1000) + if err != nil { + t.Errorf("SetDefaultRateLimit failed: %v", err) + } + + // Add a download and set its specific rate limit + id, _ := svc.Add(ts.URL, tmpDir, "rate.txt", nil, nil, false, 1, 0) + + err = svc.SetRateLimit(id, 2000) + if err != nil { + t.Errorf("SetRateLimit failed: %v", err) + } + + status, _ := svc.GetStatus(id) + if status.RateLimit != 2000 { + t.Errorf("expected rate limit 2000, got %d", status.RateLimit) + } + + // Clear it + err = svc.ClearRateLimit(id) + if err != nil { + t.Errorf("ClearRateLimit failed: %v", err) + } + + status, _ = svc.GetStatus(id) + if status.RateLimit != 1000 { // fallback to default + t.Errorf("expected rate limit 1000 (default) after clear, got %d", status.RateLimit) + } +} + +func TestLocalDownloadService_Purge(t *testing.T) { + svc, _, tmpDir := setupTestService(t) + t.Cleanup(func() { _ = svc.Shutdown() }) + + blockCh := make(chan struct{}) + purgeTs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1024") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + if r.Header.Get("Range") != "bytes=0-0" { + select { + case <-blockCh: + case <-r.Context().Done(): + } + } + })) + defer purgeTs.Close() + defer close(blockCh) + + id, _ := svc.AddWithID(purgeTs.URL, tmpDir, "purge.txt", nil, nil, "purge-id", 1, 0) + + // Wait a tiny bit for the file to be created + time.Sleep(100 * time.Millisecond) + + status, err := svc.GetStatus(id) + if err != nil { + t.Fatalf("GetStatus failed: %v", err) + } + t.Logf("GetStatus before purge returned destPath: %s", status.DestPath) + + err = svc.Purge(id) + if err != nil { + t.Errorf("Purge failed: %v", err) + } + + // Wait a tiny bit to ensure worker exited and no re-creation happened + time.Sleep(100 * time.Millisecond) + + // Check that the file was deleted + if _, err := os.Stat(filepath.Join(tmpDir, "purge.txt")); !os.IsNotExist(err) { + t.Errorf("file purge.txt was not deleted") + } + if _, err := os.Stat(filepath.Join(tmpDir, "purge.txt"+types.IncompleteSuffix)); !os.IsNotExist(err) { + t.Errorf("file purge.txt.surge was not deleted") + } +} + +func TestLocalDownloadService_HistoryAndList(t *testing.T) { + svc, ts, tmpDir := setupTestService(t) + defer ts.Close() + t.Cleanup(func() { _ = svc.Shutdown() }) + + blockCh := make(chan struct{}) + listTs := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1024") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + if r.Header.Get("Range") != "bytes=0-0" { + select { + case <-blockCh: + case <-r.Context().Done(): + } + } + })) + defer listTs.Close() + defer close(blockCh) + + _, err := svc.Add(listTs.URL, tmpDir, "list1.txt", nil, nil, false, 1, 0) + if err != nil { + t.Fatalf("Add failed: %v", err) + } + + // Add a dummy completed download to store + testutil.SeedMasterList(t, types.DownloadRecord{ + ID: "db-id", + Status: "completed", + Filename: "db.txt", + }) + + list, err := svc.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(list) < 2 { + t.Errorf("expected at least 2 items in list, got %d", len(list)) + } + + history, err := svc.History() + if err != nil { + t.Fatalf("History failed: %v", err) + } + + if len(history) != 1 { + t.Errorf("expected 1 history item, got %d", len(history)) + } + + count, _ := svc.ClearCompleted() + if count != 1 { + t.Errorf("expected 1 completed item to be cleared, got %d", count) + } +} + +func TestLocalDownloadService_Delete(t *testing.T) { + svc, ts, tmpDir := setupTestService(t) + defer ts.Close() + t.Cleanup(func() { _ = svc.Shutdown() }) + + id, _ := svc.Add(ts.URL, tmpDir, "delete.txt", nil, nil, false, 1, 0) + + err := svc.Delete(id) + if err != nil { + t.Errorf("Delete failed: %v", err) + } + + time.Sleep(100 * time.Millisecond) + + // Check if it's gone + _, err = svc.GetStatus(id) + if err == nil { + t.Error("expected error getting status of deleted download") + } +} diff --git a/internal/core/remote_service.go b/internal/service/remote_service.go similarity index 92% rename from internal/core/remote_service.go rename to internal/service/remote_service.go index cb3c035e0..4ed910644 100644 --- a/internal/core/remote_service.go +++ b/internal/service/remote_service.go @@ -1,4 +1,4 @@ -package core +package service import ( "bufio" @@ -12,8 +12,7 @@ import ( "strings" "time" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -101,14 +100,14 @@ func (s *RemoteDownloadService) List() ([]types.DownloadStatus, error) { } // History returns completed downloads -func (s *RemoteDownloadService) History() ([]types.DownloadEntry, error) { +func (s *RemoteDownloadService) History() ([]types.DownloadRecord, error) { resp, err := s.doRequest("GET", "/history", nil) if err != nil { return nil, err } defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() - var history []types.DownloadEntry + var history []types.DownloadRecord if err := json.NewDecoder(resp.Body).Decode(&history); err != nil { return nil, err } @@ -131,7 +130,7 @@ func (s *RemoteDownloadService) GetStatus(id string) (*types.DownloadStatus, err } // Add queues a new download. -func (s *RemoteDownloadService) Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { +func (s *RemoteDownloadService) Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64) (string, error) { req := map[string]interface{}{ "url": url, "path": path, @@ -140,8 +139,6 @@ func (s *RemoteDownloadService) Add(url string, path string, filename string, mi "headers": headers, "skip_approval": true, "is_explicit_category": isExplicitCategory, - "total_size": totalSize, - "supports_range": supportsRange, } if workers > 0 { req["workers"] = workers @@ -164,17 +161,15 @@ func (s *RemoteDownloadService) Add(url string, path string, filename string, mi } // AddWithID queues a new download with a caller-provided id. -func (s *RemoteDownloadService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { +func (s *RemoteDownloadService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) { req := map[string]interface{}{ - "url": url, - "path": path, - "filename": filename, - "mirrors": mirrors, - "headers": headers, - "skip_approval": true, - "id": id, - "total_size": totalSize, - "supports_range": supportsRange, + "url": url, + "path": path, + "filename": filename, + "mirrors": mirrors, + "headers": headers, + "skip_approval": true, + "id": id, } if workers > 0 { req["workers"] = workers @@ -316,12 +311,12 @@ func (s *RemoteDownloadService) SetDefaultRateLimit(rate int64) error { } // StreamEvents returns a channel that receives real-time download events via SSE. -func (s *RemoteDownloadService) StreamEvents(ctx context.Context) (<-chan interface{}, func(), error) { +func (s *RemoteDownloadService) StreamEvents(ctx context.Context) (<-chan types.DownloadEvent, func(), error) { if ctx == nil { ctx = context.Background() } streamCtx, cancel := mergeContexts(s.ctx, ctx) - ch := make(chan interface{}, 100) + ch := make(chan types.DownloadEvent, 100) go func() { defer cancel() s.streamWithReconnect(streamCtx, ch) @@ -331,11 +326,11 @@ func (s *RemoteDownloadService) StreamEvents(ctx context.Context) (<-chan interf // Publish emits an event into the service's event stream. // Remote services do not accept client-side event injection. -func (s *RemoteDownloadService) Publish(msg interface{}) error { +func (s *RemoteDownloadService) Publish(msg types.DownloadEvent) error { return fmt.Errorf("publish not supported for remote service") } -func (s *RemoteDownloadService) streamWithReconnect(ctx context.Context, ch chan interface{}) { +func (s *RemoteDownloadService) streamWithReconnect(ctx context.Context, ch chan types.DownloadEvent) { defer close(ch) backoff := 1 * time.Second for { @@ -380,7 +375,7 @@ func mergeContexts(contexts ...context.Context) (context.Context, context.Cancel } } -func (s *RemoteDownloadService) connectSSE(ctx context.Context, ch chan interface{}) error { +func (s *RemoteDownloadService) connectSSE(ctx context.Context, ch chan types.DownloadEvent) error { req, err := http.NewRequestWithContext(ctx, "GET", s.BaseURL+"/events", nil) if err != nil { return err @@ -436,14 +431,11 @@ func (s *RemoteDownloadService) connectSSE(ctx context.Context, ch chan interfac } jsonData := strings.Join(dataLines, "\n") - msg, ok, err := events.DecodeSSEMessage(eventType, []byte(jsonData)) + msg, err := types.DecodeSSEMessage([]byte(jsonData)) if err != nil { utils.Debug("SSE decode error for event=%s payload_bytes=%d: %v", eventType, len(jsonData), err) continue } - if !ok { - continue - } // Non-blocking send select { diff --git a/internal/service/remote_service_test.go b/internal/service/remote_service_test.go new file mode 100644 index 000000000..be6657239 --- /dev/null +++ b/internal/service/remote_service_test.go @@ -0,0 +1,242 @@ +package service + +import ( + _ "github.com/SurgeDM/Surge/internal/types" + + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRemoteDownloadService_SetRateLimit_ProxiesRequest(t *testing.T) { + called := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rate-limit" && r.URL.Query().Get("id") == "test-id" && r.URL.Query().Get("rate") == "100" { + called = true + } + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + svc, _ := NewRemoteDownloadService(ts.URL, "token", HTTPClientOptions{}) + t.Cleanup(func() { _ = svc.Shutdown() }) + + err := svc.SetRateLimit("test-id", 100) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !called { + t.Errorf("expected rate limit endpoint to be called") + } +} + +func TestRemoteDownloadService_ClearRateLimit_ProxiesRequest(t *testing.T) { + called := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rate-limit" && r.URL.Query().Get("id") == "test-id" && r.URL.Query().Get("inherit") == "true" { + called = true + } + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + svc, _ := NewRemoteDownloadService(ts.URL, "token", HTTPClientOptions{}) + t.Cleanup(func() { _ = svc.Shutdown() }) + + err := svc.ClearRateLimit("test-id") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !called { + t.Errorf("expected rate limit clear endpoint to be called") + } +} + +func TestRemoteDownloadService_SetGlobalRateLimit_ProxiesRequest(t *testing.T) { + called := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rate-limit/global" && r.URL.Query().Get("rate") == "200" { + called = true + } + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + svc, _ := NewRemoteDownloadService(ts.URL, "token", HTTPClientOptions{}) + t.Cleanup(func() { _ = svc.Shutdown() }) + + err := svc.SetGlobalRateLimit(200) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !called { + t.Errorf("expected global rate limit endpoint to be called") + } +} + +func TestRemoteDownloadService_SetDefaultRateLimit_ProxiesRequest(t *testing.T) { + called := false + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rate-limit/default" && r.URL.Query().Get("rate") == "300" { + called = true + } + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + svc, _ := NewRemoteDownloadService(ts.URL, "token", HTTPClientOptions{}) + t.Cleanup(func() { _ = svc.Shutdown() }) + + err := svc.SetDefaultRateLimit(300) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !called { + t.Errorf("expected default rate limit endpoint to be called") + } +} + +func TestRemoteDownloadService_NegativeRates_Rejected(t *testing.T) { + svc, _ := NewRemoteDownloadService("http://localhost:0", "token", HTTPClientOptions{}) + t.Cleanup(func() { _ = svc.Shutdown() }) + + if err := svc.SetRateLimit("id", -1); err == nil { + t.Errorf("expected error setting negative rate limit") + } + if err := svc.SetGlobalRateLimit(-1); err == nil { + t.Errorf("expected error setting negative global rate limit") + } + if err := svc.SetDefaultRateLimit(-1); err == nil { + t.Errorf("expected error setting negative default rate limit") + } +} + +func TestRemoteDownloadService_StreamEvents_ShutdownClosesChannel(t *testing.T) { + blockCh := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-blockCh: + case <-r.Context().Done(): + } + })) + defer ts.Close() + defer close(blockCh) + + svc, _ := NewRemoteDownloadService(ts.URL, "token", HTTPClientOptions{ + Timeout: 5 * time.Second, + }) + + ch, cleanup, err := svc.StreamEvents(context.Background()) + if err != nil { + t.Fatalf("StreamEvents failed: %v", err) + } + + // Shutdown the service, which should cancel the context and close the channel + if err := svc.Shutdown(); err != nil { + t.Errorf("Shutdown failed: %v", err) + } + + // The channel should be closed + select { + case _, ok := <-ch: + if ok { + t.Errorf("expected channel to be closed, but got message") + } + case <-time.After(2 * time.Second): + t.Errorf("timed out waiting for channel to close after shutdown") + } + + cleanup() +} + +func TestRemoteDownloadService_StreamEvents_CleanupClosesChannel(t *testing.T) { + blockCh := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-blockCh: + case <-r.Context().Done(): + } + })) + defer ts.Close() + defer close(blockCh) + + svc, _ := NewRemoteDownloadService(ts.URL, "token", HTTPClientOptions{}) + t.Cleanup(func() { _ = svc.Shutdown() }) + + ch, cleanup, err := svc.StreamEvents(context.Background()) + if err != nil { + t.Fatalf("StreamEvents failed: %v", err) + } + + // Cleanup should close the channel + cleanup() + + // The channel should be closed + select { + case _, ok := <-ch: + if ok { + t.Errorf("expected channel to be closed, but got message") + } + case <-time.After(2 * time.Second): + t.Errorf("timed out waiting for channel to close after cleanup") + } +} + +func TestRemoteDownloadService_StreamEvents_ReceivesMessages(t *testing.T) { + blockCh := make(chan struct{}) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + msg := "event: started\ndata: {\"download_id\":\"test-1\",\"filename\":\"test.txt\"}\n\n" + _, _ = w.Write([]byte(msg)) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + + select { + case <-blockCh: + case <-r.Context().Done(): + } + })) + defer ts.Close() + defer close(blockCh) + + svc, _ := NewRemoteDownloadService(ts.URL, "token", HTTPClientOptions{}) + t.Cleanup(func() { _ = svc.Shutdown() }) + + ch, cleanup, err := svc.StreamEvents(context.Background()) + if err != nil { + t.Fatalf("StreamEvents failed: %v", err) + } + defer cleanup() + + select { + case msg := <-ch: + startedMsg := msg + ok := true + if !ok { + t.Errorf("expected DownloadStartedMsg, got %T", msg) + } + if startedMsg.DownloadID != "test-1" { + t.Errorf("expected DownloadID test-1, got %s", startedMsg.DownloadID) + } + case <-time.After(2 * time.Second): + t.Errorf("timed out waiting for message") + } +} diff --git a/internal/engine/state/db.go b/internal/store/db.go similarity index 84% rename from internal/engine/state/db.go rename to internal/store/db.go index 584fdb9bc..caa6fe4e3 100644 --- a/internal/engine/state/db.go +++ b/internal/store/db.go @@ -1,4 +1,4 @@ -package state +package store import ( "encoding/gob" @@ -29,16 +29,28 @@ func Configure(path string) { } func ensureDirs() error { - if !configured || baseDir == "" { + masterMu.RLock() + isConfigured := configured + dir := baseDir + masterMu.RUnlock() + return ensureDirsInternal(isConfigured, dir) +} + +func ensureDirsLocked() error { + return ensureDirsInternal(configured, baseDir) +} + +func ensureDirsInternal(isConfigured bool, dir string) error { + if !isConfigured || dir == "" { return fmt.Errorf("state backend not configured") } - detailsDir := filepath.Join(baseDir, "details") + detailsDir := filepath.Join(dir, "details") if err := os.MkdirAll(detailsDir, 0o755); err != nil { return err } cleanupMu.Lock() cleanupOnce.Do(func() { - cleanupOrphans(baseDir) + cleanupOrphans(dir) cleanupOrphans(detailsDir) }) cleanupMu.Unlock() diff --git a/internal/engine/state/state.go b/internal/store/state.go similarity index 86% rename from internal/engine/state/state.go rename to internal/store/state.go index 7d78f1154..64b0ad4a2 100644 --- a/internal/engine/state/state.go +++ b/internal/store/state.go @@ -1,4 +1,4 @@ -package state +package store import ( "crypto/md5" @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" "github.com/google/uuid" ) @@ -32,12 +32,12 @@ type SaveStateOptions struct { type MasterState struct { Version int - Downloads []types.DownloadEntry + Downloads []types.DownloadRecord } type DetailState struct { Version int - State *types.DownloadState + State *types.DownloadRecord } func URLHash(url string) string { @@ -49,15 +49,15 @@ func getMasterPath() string { return filepath.Join(baseDir, "master.gob") } -func getDetailPath(id string) string { - return filepath.Join(baseDir, "details", id+".gob") +func getDetailPath(dir, id string) string { + return filepath.Join(dir, "details", id+".gob") } -func SaveState(url string, destPath string, state *types.DownloadState) error { +func SaveState(url string, destPath string, state *types.DownloadRecord) error { return SaveStateWithOptions(url, destPath, state, SaveStateOptions{}) } -func SaveStateWithOptions(url string, destPath string, state *types.DownloadState, opts SaveStateOptions) error { +func SaveStateWithOptions(url string, destPath string, state *types.DownloadRecord, opts SaveStateOptions) error { if state.ID == "" { state.ID = uuid.New().String() } @@ -95,7 +95,7 @@ func SaveStateWithOptions(url string, destPath string, state *types.DownloadStat } ds := DetailState{ - Version: 1, + Version: 2, State: state, } @@ -103,14 +103,24 @@ func SaveStateWithOptions(url string, destPath string, state *types.DownloadStat return err } - detailPath := getDetailPath(state.ID) + // Acquire the write lock for both the detail write and the master list update. + // Snapshot baseDir here so both operations use the same directory — eliminating + // the race window that existed when baseDir was re-read under a separate RLock + // after ensureDirs() had already released its own RLock. + masterMu.Lock() + defer masterMu.Unlock() + + dir := baseDir + if dir == "" { + return fmt.Errorf("state backend not configured") + } + + detailPath := getDetailPath(dir, state.ID) if err := atomicWrite(detailPath, ds); err != nil { return fmt.Errorf("failed to write detail state: %w", err) } // Update the lightweight index (MasterList) - masterMu.Lock() - defer masterMu.Unlock() list, err := loadMasterListUnlocked() if err != nil { return fmt.Errorf("failed to load master list: %w", err) @@ -220,7 +230,7 @@ func parseStoredHash(storedHash string) (algo, value string) { } } -func LoadState(url string, destPath string) (*types.DownloadState, error) { +func LoadState(url string, destPath string) (*types.DownloadRecord, error) { masterList, err := LoadMasterList() if err != nil { return nil, err @@ -239,8 +249,12 @@ func LoadState(url string, destPath string) (*types.DownloadState, error) { return nil, fmt.Errorf("state not found: %w", os.ErrNotExist) } + masterMu.RLock() + dir := baseDir + masterMu.RUnlock() + var ds DetailState - if err := loadGob(getDetailPath(foundID), &ds); err != nil { + if err := loadGob(getDetailPath(dir, foundID), &ds); err != nil { return nil, fmt.Errorf("failed to load detail state: %w", err) } @@ -251,12 +265,17 @@ func LoadState(url string, destPath string) (*types.DownloadState, error) { return ds.State, nil } -func LoadStates(ids []string) (map[string]*types.DownloadState, error) { - states := make(map[string]*types.DownloadState) +func LoadStates(ids []string) (map[string]*types.DownloadRecord, error) { + states := make(map[string]*types.DownloadRecord) var errs []error + + masterMu.RLock() + dir := baseDir + masterMu.RUnlock() + for _, id := range ids { var ds DetailState - if err := loadGob(getDetailPath(id), &ds); err != nil { + if err := loadGob(getDetailPath(dir, id), &ds); err != nil { if !os.IsNotExist(err) { utils.Debug("LoadStates: failed to load state for %s: %v", id, err) errs = append(errs, fmt.Errorf("id %s: %w", id, err)) @@ -278,7 +297,7 @@ func DeleteState(id string) error { defer masterMu.Unlock() // Remove detail file, propagating real errors (but not "file not found"). - if err := utils.RemoveFile(getDetailPath(id)); err != nil && !os.IsNotExist(err) { + if err := utils.RemoveFile(getDetailPath(baseDir, id)); err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to remove detail state: %w", err) } @@ -287,7 +306,7 @@ func DeleteState(id string) error { if err != nil { return err } - out := make([]types.DownloadEntry, 0, len(list.Downloads)) + out := make([]types.DownloadRecord, 0, len(list.Downloads)) for _, e := range list.Downloads { if e.ID != id { out = append(out, e) @@ -301,7 +320,7 @@ func DeleteTasks(id string) error { masterMu.Lock() defer masterMu.Unlock() var ds DetailState - detailPath := getDetailPath(id) + detailPath := getDetailPath(baseDir, id) if err := loadGob(detailPath, &ds); err != nil { if os.IsNotExist(err) { return nil // nothing to clear @@ -322,36 +341,24 @@ func LoadMasterList() (*types.MasterList, error) { masterMu.RLock() defer masterMu.RUnlock() - if baseDir == "" { - return &types.MasterList{Downloads: []types.DownloadEntry{}}, nil - } - - var ms MasterState - err := loadGob(getMasterPath(), &ms) - if err != nil { - if os.IsNotExist(err) { - return &types.MasterList{Downloads: []types.DownloadEntry{}}, nil - } - return nil, err - } - return &types.MasterList{Downloads: ms.Downloads}, nil + return loadMasterListUnlocked() } func saveMasterListLocked(list *types.MasterList) error { if baseDir == "" { return fmt.Errorf("state backend not configured") } - if err := ensureDirs(); err != nil { + if err := ensureDirsLocked(); err != nil { return err } ms := MasterState{ - Version: 1, + Version: 2, Downloads: list.Downloads, } return atomicWrite(getMasterPath(), ms) } -func AddToMasterList(entry types.DownloadEntry) error { +func AddToMasterList(entry types.DownloadRecord) error { masterMu.Lock() defer masterMu.Unlock() @@ -382,17 +389,19 @@ func AddToMasterList(entry types.DownloadEntry) error { func loadMasterListUnlocked() (*types.MasterList, error) { if baseDir == "" { - return &types.MasterList{Downloads: []types.DownloadEntry{}}, nil + return &types.MasterList{Downloads: []types.DownloadRecord{}}, nil } var ms MasterState if err := loadGob(getMasterPath(), &ms); err != nil { if os.IsNotExist(err) { - return &types.MasterList{Downloads: []types.DownloadEntry{}}, nil + return &types.MasterList{Downloads: []types.DownloadRecord{}}, nil } return nil, err } - if ms.Version != 1 { - return nil, fmt.Errorf("unsupported master list version: %d", ms.Version) + if ms.Version != 2 { + return nil, fmt.Errorf("master list has unsupported version %d (expected 2); "+ + "run 'surge --reset-settings' or remove %s to start fresh", + ms.Version, getMasterPath()) } return &types.MasterList{Downloads: ms.Downloads}, nil } @@ -405,7 +414,7 @@ func RemoveFromMasterList(id string) error { if err != nil { return err } - out := []types.DownloadEntry{} + out := []types.DownloadRecord{} for _, e := range list.Downloads { if e.ID != id { out = append(out, e) @@ -415,7 +424,7 @@ func RemoveFromMasterList(id string) error { return saveMasterListLocked(list) } -func GetDownload(id string) (*types.DownloadEntry, error) { +func GetDownload(id string) (*types.DownloadRecord, error) { masterMu.RLock() defer masterMu.RUnlock() @@ -431,12 +440,12 @@ func GetDownload(id string) (*types.DownloadEntry, error) { return nil, nil } -func LoadPausedDownloads() ([]types.DownloadEntry, error) { +func LoadPausedDownloads() ([]types.DownloadRecord, error) { list, err := LoadMasterList() if err != nil { return nil, err } - var paused []types.DownloadEntry + var paused []types.DownloadRecord for _, e := range list.Downloads { if e.Status == "paused" || e.Status == "queued" { paused = append(paused, e) @@ -445,12 +454,12 @@ func LoadPausedDownloads() ([]types.DownloadEntry, error) { return paused, nil } -func LoadCompletedDownloads() ([]types.DownloadEntry, error) { +func LoadCompletedDownloads() ([]types.DownloadRecord, error) { list, err := LoadMasterList() if err != nil { return nil, err } - var completed []types.DownloadEntry + var completed []types.DownloadRecord for _, e := range list.Downloads { if e.Status == "completed" { completed = append(completed, e) @@ -549,7 +558,7 @@ func ResumeAllDownloads() error { return saveMasterListLocked(list) } -func ListAllDownloads() ([]types.DownloadEntry, error) { +func ListAllDownloads() ([]types.DownloadRecord, error) { list, err := LoadMasterList() if err != nil { return nil, err @@ -574,11 +583,11 @@ func removeDownloadsByStatus(status string) (int64, error) { return 0, err } var count int64 - out := []types.DownloadEntry{} + out := []types.DownloadRecord{} for _, e := range list.Downloads { if e.Status == status { count++ - _ = utils.RemoveFile(getDetailPath(e.ID)) + _ = utils.RemoveFile(getDetailPath(baseDir, e.ID)) } else { out = append(out, e) } @@ -705,7 +714,7 @@ func ValidateIntegrity() (int, error) { expectedSurgePaths := make(map[string]struct{}) candidateDirs := make(map[string]struct{}) - var out []types.DownloadEntry + var out []types.DownloadRecord for _, e := range list.Downloads { if e.DestPath == "" { @@ -726,7 +735,7 @@ func ValidateIntegrity() (int, error) { if os.IsNotExist(statErr) { // File missing - remove orphaned DB entry utils.Debug("Integrity: .surge file missing for %s, removing entry %s", e.DestPath, e.ID) - _ = utils.RemoveFile(getDetailPath(e.ID)) + _ = utils.RemoveFile(getDetailPath(baseDir, e.ID)) removed++ continue } @@ -736,7 +745,7 @@ func ValidateIntegrity() (int, error) { // If we have a stored hash, verify it var ds DetailState - if err := loadGob(getDetailPath(e.ID), &ds); err == nil && ds.State != nil && ds.State.FileHash != "" { + if err := loadGob(getDetailPath(baseDir, e.ID), &ds); err == nil && ds.State != nil && ds.State.FileHash != "" { matches, err := compareAgainstStoredFileHash(surgePath, ds.State.FileHash) if err != nil { return removed, fmt.Errorf("failed to verify hash for %s: %w", surgePath, err) @@ -745,7 +754,7 @@ func ValidateIntegrity() (int, error) { // File has been tampered with - remove entry and corrupted file utils.Debug("Integrity: hash mismatch for %s (expected %s), removing", surgePath, ds.State.FileHash) _ = utils.RemoveFile(surgePath) - _ = utils.RemoveFile(getDetailPath(e.ID)) + _ = utils.RemoveFile(getDetailPath(baseDir, e.ID)) removed++ continue } diff --git a/internal/engine/state/state_test.go b/internal/store/state_test.go similarity index 88% rename from internal/engine/state/state_test.go rename to internal/store/state_test.go index 922a34513..fb2d89c2d 100644 --- a/internal/engine/state/state_test.go +++ b/internal/store/state_test.go @@ -1,4 +1,4 @@ -package state +package store import ( "errors" @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" "github.com/google/uuid" ) @@ -72,7 +72,7 @@ func TestSaveLoadState(t *testing.T) { testDestPath := filepath.Join(tmpDir, "testfile.zip") id := uuid.New().String() - originalState := &types.DownloadState{ + originalState := &types.DownloadRecord{ ID: id, URL: testURL, DestPath: testDestPath, @@ -89,7 +89,7 @@ func TestSaveLoadState(t *testing.T) { if err := SaveState(testURL, testDestPath, originalState); err != nil { t.Fatalf("SaveState failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: id, URL: testURL, DestPath: testDestPath, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: id, URL: testURL, DestPath: testDestPath, Status: "paused"}) // Load state loadedState, err := LoadState(testURL, testDestPath) @@ -143,7 +143,7 @@ func TestSaveStateWithOptions_ComputesHashForSmallFile(t *testing.T) { t.Fatal("computeFileHashMD5WithTimeout unexpectedly timed out") } - downloadState := &types.DownloadState{ + downloadState := &types.DownloadRecord{ ID: "hash-small-id", URL: testURL, DestPath: testDestPath, @@ -161,7 +161,7 @@ func TestSaveStateWithOptions_ComputesHashForSmallFile(t *testing.T) { if err != nil { t.Fatalf("SaveStateWithOptions failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: "hash-small-id", URL: testURL, DestPath: testDestPath, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: "hash-small-id", URL: testURL, DestPath: testDestPath, Status: "paused"}) loaded, err := LoadState(testURL, testDestPath) if err != nil { @@ -185,7 +185,7 @@ func TestSaveStateWithOptions_SkipsHashOnTimeoutButPersistsState(t *testing.T) { t.Fatalf("failed to write .surge file: %v", err) } - downloadState := &types.DownloadState{ + downloadState := &types.DownloadRecord{ ID: "hash-large-id", URL: testURL, DestPath: testDestPath, @@ -204,7 +204,7 @@ func TestSaveStateWithOptions_SkipsHashOnTimeoutButPersistsState(t *testing.T) { if err != nil { t.Fatalf("SaveStateWithOptions failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: "hash-large-id", URL: testURL, DestPath: testDestPath, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: "hash-large-id", URL: testURL, DestPath: testDestPath, Status: "paused"}) loaded, err := LoadState(testURL, testDestPath) if err != nil { @@ -239,7 +239,7 @@ func TestDeleteState(t *testing.T) { testDestPath := filepath.Join(tmpDir, "delete-test.zip") id := "test-id-delete" - state := &types.DownloadState{ + state := &types.DownloadRecord{ ID: id, URL: testURL, DestPath: testDestPath, @@ -250,7 +250,7 @@ func TestDeleteState(t *testing.T) { if err := SaveState(testURL, testDestPath, state); err != nil { t.Fatalf("SaveState failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: id, URL: testURL, DestPath: testDestPath, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: id, URL: testURL, DestPath: testDestPath, Status: "paused"}) // Verify it was saved if _, err := LoadState(testURL, testDestPath); err != nil { @@ -281,7 +281,7 @@ func TestStateOverwrite(t *testing.T) { id := "test-id-overwrite" // First pause at 30% - state1 := &types.DownloadState{ + state1 := &types.DownloadRecord{ ID: id, URL: testURL, DestPath: testDestPath, @@ -293,10 +293,10 @@ func TestStateOverwrite(t *testing.T) { if err := SaveState(testURL, testDestPath, state1); err != nil { t.Fatalf("First SaveState failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: id, URL: testURL, DestPath: testDestPath, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: id, URL: testURL, DestPath: testDestPath, Status: "paused"}) // Second pause at 80% (simulating resume + more downloading) - state2 := &types.DownloadState{ + state2 := &types.DownloadRecord{ ID: id, URL: testURL, DestPath: testDestPath, @@ -338,7 +338,7 @@ func TestDuplicateURLStateIsolation(t *testing.T) { // The new DB schema has ID as Primary Key. // If we don't provide ID, SaveState generates one. - state1 := &types.DownloadState{ + state1 := &types.DownloadRecord{ URL: testURL, DestPath: dest1, TotalSize: 1000000, @@ -346,7 +346,7 @@ func TestDuplicateURLStateIsolation(t *testing.T) { Tasks: []types.Task{{Offset: 100000, Length: 900000}}, Filename: "samefile.zip", } - state2 := &types.DownloadState{ + state2 := &types.DownloadRecord{ URL: testURL, DestPath: dest2, TotalSize: 1000000, @@ -354,7 +354,7 @@ func TestDuplicateURLStateIsolation(t *testing.T) { Tasks: []types.Task{{Offset: 500000, Length: 500000}}, Filename: "samefile(1).zip", } - state3 := &types.DownloadState{ + state3 := &types.DownloadRecord{ URL: testURL, DestPath: dest3, TotalSize: 1000000, @@ -367,15 +367,15 @@ func TestDuplicateURLStateIsolation(t *testing.T) { if err := SaveState(testURL, dest1, state1); err != nil { t.Fatalf("SaveState 1 failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: state1.ID, URL: testURL, DestPath: dest1, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: state1.ID, URL: testURL, DestPath: dest1, Status: "paused"}) if err := SaveState(testURL, dest2, state2); err != nil { t.Fatalf("SaveState 2 failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: state2.ID, URL: testURL, DestPath: dest2, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: state2.ID, URL: testURL, DestPath: dest2, Status: "paused"}) if err := SaveState(testURL, dest3, state3); err != nil { t.Fatalf("SaveState 3 failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: state3.ID, URL: testURL, DestPath: dest3, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: state3.ID, URL: testURL, DestPath: dest3, Status: "paused"}) // Load and verify each has its correct state loaded1, err := LoadState(testURL, dest1) @@ -422,7 +422,7 @@ func TestUpdateStatus(t *testing.T) { defer CloseDB() id := "test-status-id" - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: id, URL: "https://example.com/status-test.zip", DestPath: filepath.Join(tmpDir, "status-test.zip"), @@ -434,7 +434,7 @@ func TestUpdateStatus(t *testing.T) { t.Fatalf("AddToMasterList failed: %v", err) } // Mock task data using SaveState - state := &types.DownloadState{ + state := &types.DownloadRecord{ ID: id, URL: "https://example.com/status-test.zip", DestPath: filepath.Join(tmpDir, "status-test.zip"), @@ -481,7 +481,7 @@ func TestPauseAllDownloads(t *testing.T) { defer CloseDB() // Add downloads with various statuses - entries := []types.DownloadEntry{ + entries := []types.DownloadRecord{ {ID: "dl-1", URL: "https://a.com/1", DestPath: "/tmp/1", Status: "downloading"}, {ID: "dl-2", URL: "https://a.com/2", DestPath: "/tmp/2", Status: "queued"}, {ID: "dl-3", URL: "https://a.com/3", DestPath: "/tmp/3", Status: "completed"}, @@ -524,7 +524,7 @@ func TestResumeAllDownloads(t *testing.T) { defer CloseDB() // Add paused and other downloads - entries := []types.DownloadEntry{ + entries := []types.DownloadRecord{ {ID: "dl-1", URL: "https://b.com/1", DestPath: "/tmp/1", Status: "paused"}, {ID: "dl-2", URL: "https://b.com/2", DestPath: "/tmp/2", Status: "paused"}, {ID: "dl-3", URL: "https://b.com/3", DestPath: "/tmp/3", Status: "completed"}, @@ -567,7 +567,7 @@ func TestListAllDownloads(t *testing.T) { defer CloseDB() // Add downloads - entries := []types.DownloadEntry{ + entries := []types.DownloadRecord{ {ID: "list-1", URL: "https://c.com/1", DestPath: "/tmp/1", Status: "completed"}, {ID: "list-2", URL: "https://c.com/2", DestPath: "/tmp/2", Status: "paused"}, } @@ -614,7 +614,7 @@ func TestRemoveCompletedDownloads(t *testing.T) { defer CloseDB() // Add downloads with various statuses - entries := []types.DownloadEntry{ + entries := []types.DownloadRecord{ {ID: "rm-1", URL: "https://d.com/1", DestPath: "/tmp/1", Status: "completed"}, {ID: "rm-2", URL: "https://d.com/2", DestPath: "/tmp/2", Status: "completed"}, {ID: "rm-3", URL: "https://d.com/3", DestPath: "/tmp/3", Status: "paused"}, @@ -658,8 +658,8 @@ func TestMirrorsPersistence(t *testing.T) { "https://mirror2.example.com/file.zip", } - // 1. Test DownloadState (Resume) - state := &types.DownloadState{ + // 1. Test DownloadRecord (Resume) + state := &types.DownloadRecord{ ID: "mirror-state-id", URL: testURL, DestPath: testDestPath, @@ -672,7 +672,7 @@ func TestMirrorsPersistence(t *testing.T) { if err := SaveState(testURL, testDestPath, state); err != nil { t.Fatalf("SaveState failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: "mirror-state-id", URL: testURL, DestPath: testDestPath, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: "mirror-state-id", URL: testURL, DestPath: testDestPath, Status: "paused"}) loadedState, err := LoadState(testURL, testDestPath) if err != nil { @@ -687,8 +687,8 @@ func TestMirrorsPersistence(t *testing.T) { } } - // 2. Test DownloadEntry (Master List / Completed) - entry := types.DownloadEntry{ + // 2. Test DownloadRecord (Master List / Completed) + entry := types.DownloadRecord{ ID: "mirror-entry-id", URL: testURL, DestPath: testDestPath + ".completed", @@ -738,7 +738,7 @@ func TestValidateIntegrity_MissingFile(t *testing.T) { destPath := filepath.Join(tmpDir, "missing.zip") // Insert a paused download - but DO NOT create the .surge file - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: "integrity-missing", URL: "https://example.com/missing.zip", DestPath: destPath, @@ -773,9 +773,9 @@ func TestValidateIntegrity_MissingFile(t *testing.T) { t.Error("Entry should have been removed after integrity check") } - // Verify detail state is deleted - if _, err := os.Stat(getDetailPath(entry.ID)); !os.IsNotExist(err) { - t.Errorf("expected details state to be removed") + // Check if detail file was deleted + if _, err := os.Stat(getDetailPath(tmpDir, entry.ID)); !os.IsNotExist(err) { + t.Errorf("Expected detail file to be removed, got: %v", err) } } @@ -800,7 +800,7 @@ func TestValidateIntegrity_ValidFile(t *testing.T) { } // Insert a paused download with correct file hash - if err := AddToMasterList(types.DownloadEntry{ + if err := AddToMasterList(types.DownloadRecord{ ID: "integrity-valid", URL: "https://example.com/valid.zip", DestPath: destPath, @@ -811,7 +811,7 @@ func TestValidateIntegrity_ValidFile(t *testing.T) { } // Set file_hash directly in DB (simulating SaveState having computed it) - state := &types.DownloadState{ + state := &types.DownloadRecord{ ID: "integrity-valid", URL: "https://example.com/valid.zip", DestPath: destPath, @@ -819,7 +819,7 @@ func TestValidateIntegrity_ValidFile(t *testing.T) { FileHash: expectedHash, } ds := DetailState{Version: 1, State: state} - _ = atomicWrite(getDetailPath("integrity-valid"), ds) + _ = atomicWrite(getDetailPath(tmpDir, "integrity-valid"), ds) // Run integrity check - file exists with matching hash, should keep it removed, err := ValidateIntegrity() @@ -856,7 +856,7 @@ func TestValidateIntegrity_TamperedFile(t *testing.T) { } // Insert entry with a WRONG hash (simulating tampering) - if err := AddToMasterList(types.DownloadEntry{ + if err := AddToMasterList(types.DownloadRecord{ ID: "integrity-tampered", URL: "https://example.com/tampered.zip", DestPath: destPath, @@ -867,7 +867,7 @@ func TestValidateIntegrity_TamperedFile(t *testing.T) { } // Set a fake hash that won't match the file content - state := &types.DownloadState{ + state := &types.DownloadRecord{ ID: "integrity-tampered", URL: "https://example.com/tampered.zip", DestPath: destPath, @@ -875,7 +875,7 @@ func TestValidateIntegrity_TamperedFile(t *testing.T) { FileHash: "0000000000000000000000000000000000000000000000000000000000000000", } ds := DetailState{Version: 1, State: state} - _ = atomicWrite(getDetailPath("integrity-tampered"), ds) + _ = atomicWrite(getDetailPath(tmpDir, "integrity-tampered"), ds) // Run integrity check - hash mismatch, entry AND file should be removed removed, err := ValidateIntegrity() @@ -904,7 +904,7 @@ func TestValidateIntegrity_CompletedIgnored(t *testing.T) { defer CloseDB() // Insert a completed download - should NOT be touched by integrity check - if err := AddToMasterList(types.DownloadEntry{ + if err := AddToMasterList(types.DownloadRecord{ ID: "integrity-completed", URL: "https://example.com/done.zip", DestPath: filepath.Join(tmpDir, "done.zip"), @@ -937,7 +937,7 @@ func TestValidateIntegrity_QueuedWithoutPartialFileRemoved(t *testing.T) { destPath := filepath.Join(tmpDir, "queued-never-started.bin") - if err := AddToMasterList(types.DownloadEntry{ + if err := AddToMasterList(types.DownloadRecord{ ID: "integrity-queued-fresh", URL: "https://example.com/queued-never-started.bin", DestPath: destPath, @@ -972,7 +972,7 @@ func TestValidateIntegrity_DeletesOrphanSurgeFile(t *testing.T) { defer CloseDB() // Seed one normal completed entry so tmpDir is a known download directory. - if err := AddToMasterList(types.DownloadEntry{ + if err := AddToMasterList(types.DownloadRecord{ ID: "integrity-known-dir", URL: "https://example.com/known.zip", DestPath: filepath.Join(tmpDir, "known.zip"), @@ -1009,7 +1009,7 @@ func TestValidateIntegrity_PreservesNonCompletedSurgeFile(t *testing.T) { destPath := filepath.Join(tmpDir, "active.bin") surgePath := destPath + types.IncompleteSuffix - if err := AddToMasterList(types.DownloadEntry{ + if err := AddToMasterList(types.DownloadRecord{ ID: "integrity-active", URL: "https://example.com/active.bin", DestPath: destPath, @@ -1045,7 +1045,7 @@ func TestAvgSpeedPersistence(t *testing.T) { defer func() { _ = os.RemoveAll(tmpDir) }() defer CloseDB() - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: "speed-test", URL: "https://example.com/speed.zip", DestPath: filepath.Join(tmpDir, "speed.zip"), @@ -1100,7 +1100,7 @@ func TestNormalizeStaleDownloads(t *testing.T) { defer func() { _ = os.RemoveAll(tmpDir) }() defer CloseDB() - entries := []types.DownloadEntry{ + entries := []types.DownloadRecord{ {ID: "stale-1", URL: "https://a.com/1", DestPath: "/tmp/1", Status: "downloading"}, {ID: "stale-2", URL: "https://a.com/2", DestPath: "/tmp/2", Status: "downloading"}, {ID: "ok-3", URL: "https://a.com/3", DestPath: "/tmp/3", Status: "paused"}, @@ -1152,7 +1152,7 @@ func TestSaveLoadState_PreservesOverrideFields(t *testing.T) { testDestPath := filepath.Join(tmpDir, "override-state.zip") id := uuid.New().String() - originalState := &types.DownloadState{ + originalState := &types.DownloadRecord{ ID: id, URL: testURL, DestPath: testDestPath, @@ -1166,7 +1166,7 @@ func TestSaveLoadState_PreservesOverrideFields(t *testing.T) { if err := SaveState(testURL, testDestPath, originalState); err != nil { t.Fatalf("SaveState failed: %v", err) } - _ = AddToMasterList(types.DownloadEntry{ID: id, URL: testURL, DestPath: testDestPath, Status: "paused"}) + _ = AddToMasterList(types.DownloadRecord{ID: id, URL: testURL, DestPath: testDestPath, Status: "paused"}) loadedState, err := LoadState(testURL, testDestPath) if err != nil { @@ -1186,7 +1186,7 @@ func TestAddGetDownload_PreservesOverrideFields(t *testing.T) { defer CloseDB() id := "override-entry-id" - entry := types.DownloadEntry{ + entry := types.DownloadRecord{ ID: id, URL: "https://test.example.com/override-entry.zip", DestPath: filepath.Join(tmpDir, "override-entry.zip"), @@ -1214,3 +1214,55 @@ func TestAddGetDownload_PreservesOverrideFields(t *testing.T) { t.Errorf("MinChunkSize = %d, want %d", loaded.MinChunkSize, 5*utils.MiB) } } + +// ============================================================================= +// Versioning Tests +// ============================================================================= + +func TestLoadMasterList_UnsupportedVersion_ReturnsError(t *testing.T) { + tmpDir := setupTestDB(t) + defer func() { _ = os.RemoveAll(tmpDir) }() + defer CloseDB() + + // Write a v1 master state + ms := MasterState{ + Version: 1, + Downloads: []types.DownloadRecord{}, + } + if err := atomicWrite(getMasterPath(), ms); err != nil { + t.Fatalf("failed to write v1 state: %v", err) + } + + // Verify LoadMasterList returns an error + list, err := LoadMasterList() + if err == nil { + t.Error("expected error for unsupported version") + } + if list != nil { + t.Error("expected nil list for unsupported version") + } +} + +func TestLoadMasterListUnlocked_UnsupportedVersion_ReturnsError(t *testing.T) { + tmpDir := setupTestDB(t) + defer func() { _ = os.RemoveAll(tmpDir) }() + defer CloseDB() + + // Write a v3 master state + ms := MasterState{ + Version: 3, + Downloads: []types.DownloadRecord{}, + } + if err := atomicWrite(getMasterPath(), ms); err != nil { + t.Fatalf("failed to write v3 state: %v", err) + } + + // Verify loadMasterListUnlocked returns an error + list, err := loadMasterListUnlocked() + if err == nil { + t.Error("expected error for unsupported version") + } + if list != nil { + t.Error("expected nil list for unsupported version") + } +} diff --git a/internal/engine/concurrent/chunk_test.go b/internal/strategy/concurrent/chunk_test.go similarity index 98% rename from internal/engine/concurrent/chunk_test.go rename to internal/strategy/concurrent/chunk_test.go index aa6b3a811..ec1293178 100644 --- a/internal/engine/concurrent/chunk_test.go +++ b/internal/strategy/concurrent/chunk_test.go @@ -3,7 +3,7 @@ package concurrent import ( "testing" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" "github.com/stretchr/testify/assert" ) diff --git a/internal/engine/concurrent/concurrent_test.go b/internal/strategy/concurrent/concurrent_test.go similarity index 92% rename from internal/engine/concurrent/concurrent_test.go rename to internal/strategy/concurrent/concurrent_test.go index 4d5181d2d..477cf154f 100644 --- a/internal/engine/concurrent/concurrent_test.go +++ b/internal/strategy/concurrent/concurrent_test.go @@ -11,29 +11,16 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/store" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) -// Helper to init state just for tests (avoiding global init if possible, -// using temporary directories for each test) func initTestState(t *testing.T) (string, func()) { - state.CloseDB() // Ensure any previous DB is closed - - tmpDir, cleanup, err := testutil.TempDir("surge-test") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - dbPath := filepath.Join(tmpDir, "surge.db") - state.Configure(dbPath) - - return tmpDir, func() { - state.CloseDB() // Close DB before removing dir - cleanup() - } + tmpDir := testutil.SetupStateDB(t) + return tmpDir, func() {} } func TestConcurrentDownloader_Download(t *testing.T) { @@ -48,7 +35,7 @@ func TestConcurrentDownloader_Download(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "test_download.bin") - state := types.NewProgressState("test-id", fileSize) + state := progress.New("test-id", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 4} downloader := NewConcurrentDownloader("test-id", nil, state, runtime) @@ -88,7 +75,7 @@ func TestConcurrentDownloader_WithLatency(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "latency_test.bin") - state := types.NewProgressState("latency-test", fileSize) + state := progress.New("latency-test", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 2} downloader := NewConcurrentDownloader("latency-id", nil, state, runtime) @@ -133,7 +120,7 @@ func TestConcurrentDownloader_SlowDownload(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "slow_test.bin") - state := types.NewProgressState("slow-test", fileSize) + state := progress.New("slow-test", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 4} downloader := NewConcurrentDownloader("slow-id", nil, state, runtime) @@ -174,7 +161,7 @@ func TestConcurrentDownloader_RespectServerConnectionLimit(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "connlimit_test.bin") - state := types.NewProgressState("connlimit-test", fileSize) + state := progress.New("connlimit-test", fileSize) // Client configured for more connections than server allows runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 8, // More than server allows @@ -222,7 +209,7 @@ func TestConcurrentDownloader_ContentIntegrity(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "integrity_test.bin") - state := types.NewProgressState("integrity-test", fileSize) + state := progress.New("integrity-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 4, MinChunkSize: 16 * utils.KiB, @@ -295,7 +282,7 @@ func TestConcurrentDownloader_SmallFile(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "small_test.bin") - state := types.NewProgressState("test-download", fileSize) + state := progress.New("test-download", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 4, MinChunkSize: 16 * utils.KiB, @@ -321,7 +308,6 @@ func TestConcurrentDownloader_SmallFile(t *testing.T) { if err := testutil.VerifyFileSize(destPath+types.IncompleteSuffix, fileSize); err != nil { t.Error(err) } - } func TestConcurrentDownloader_MediumFile(t *testing.T) { @@ -336,7 +322,7 @@ func TestConcurrentDownloader_MediumFile(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "medium_test.bin") - state := types.NewProgressState("test-download", fileSize) + state := progress.New("test-download", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 8, MinChunkSize: 64 * utils.KiB, @@ -382,7 +368,7 @@ func TestConcurrentDownloader_Cancellation(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "cancel_test.bin") - state := types.NewProgressState("cancel-test", fileSize) + state := progress.New("cancel-test", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 4} downloader := NewConcurrentDownloader("cancel-id", nil, state, runtime) @@ -424,7 +410,7 @@ func TestConcurrentDownloader_PauseAtCompletionFinalizesAsCompleted(t *testing.T defer server.Close() destPath := filepath.Join(tmpDir, "pause_completion_test.bin") - progressState := types.NewProgressState("pause-complete-test", fileSize) + progressState := progress.New("pause-complete-test", fileSize) progressState.Pause() runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 4, @@ -450,7 +436,6 @@ func TestConcurrentDownloader_PauseAtCompletionFinalizesAsCompleted(t *testing.T if err := testutil.VerifyFileSize(destPath+types.IncompleteSuffix, fileSize); err != nil { t.Fatal(err) } - } func TestConcurrentDownloader_ProgressTracking(t *testing.T) { @@ -465,7 +450,7 @@ func TestConcurrentDownloader_ProgressTracking(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "progress_test.bin") - state := types.NewProgressState("progress-test", fileSize) + state := progress.New("progress-test", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 4} downloader := NewConcurrentDownloader("progress-id", nil, state, runtime) @@ -487,7 +472,7 @@ func TestConcurrentDownloader_ProgressTracking(t *testing.T) { t.Fatalf("Download failed: %v", err) } - finalDownloaded := state.Downloaded.Load() + finalDownloaded := state.Bytes.Downloaded.Load() if finalDownloaded != fileSize { t.Errorf("Final downloaded %d != file size %d", finalDownloaded, fileSize) } @@ -508,7 +493,7 @@ func TestConcurrentDownloader_RetryOnFailure(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "retry_test.bin") - state := types.NewProgressState("retry-test", fileSize) + state := progress.New("retry-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 2, MaxTaskRetries: 10, // Need more retries since each attempt only gets 20KB @@ -554,7 +539,7 @@ func TestConcurrentDownloader_FailOnNthRequest(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "failnth_test.bin") - state := types.NewProgressState("failnth-test", fileSize) + state := progress.New("failnth-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 1, // Single connection for predictable request order MaxTaskRetries: 5, @@ -610,8 +595,8 @@ func TestConcurrentDownloader_ResumePartialDownload(t *testing.T) { remainingTasks := []types.Task{ {Offset: partialSize, Length: fileSize - partialSize}, } - // Need to check if DownloadState struct is compatible - savedState := &types.DownloadState{ + // Need to check if DownloadRecord struct is compatible + savedState := &types.DownloadRecord{ ID: downloadID, URL: server.URL(), DestPath: destPath, @@ -619,14 +604,14 @@ func TestConcurrentDownloader_ResumePartialDownload(t *testing.T) { Downloaded: partialSize, Tasks: remainingTasks, Filename: "resume_test.bin", - URLHash: state.URLHash(server.URL()), + URLHash: store.URLHash(server.URL()), } - if err := state.SaveState(server.URL(), destPath, savedState); err != nil { + if err := store.SaveState(server.URL(), destPath, savedState); err != nil { t.Fatalf("Failed to save state: %v", err) } // Now resume download - progressState := types.NewProgressState("resume-test", fileSize) + progressState := progress.New("resume-test", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 2} downloader := NewConcurrentDownloader(downloadID, nil, progressState, runtime) @@ -643,7 +628,6 @@ func TestConcurrentDownloader_ResumePartialDownload(t *testing.T) { if err != nil { t.Fatalf("Resume download failed: %v", err) } - } // ============================================================================= @@ -757,7 +741,7 @@ func TestConcurrentDownloader_Download_BootstrapSize(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "bootstrap_test.bin") - state := types.NewProgressState("bootstrap-id", 0) // Unknown size + state := progress.New("bootstrap-id", 0) // Unknown size runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 1} downloader := NewConcurrentDownloader("bootstrap-id", nil, state, runtime) @@ -790,7 +774,7 @@ func TestConcurrentDownloader_Download_BootstrapFail_Non206(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "bootstrap_fail.bin") - state := types.NewProgressState("bootstrap-fail-id", 0) + state := progress.New("bootstrap-fail-id", 0) downloader := NewConcurrentDownloader("bootstrap-fail-id", nil, state, nil) if f, err := os.Create(destPath + ".surge"); err == nil { @@ -818,7 +802,7 @@ func TestConcurrentDownloader_Download_BootstrapFail_InvalidRange(t *testing.T) defer server.Close() destPath := filepath.Join(tmpDir, "bootstrap_invalid.bin") - state := types.NewProgressState("bootstrap-invalid-id", 0) + state := progress.New("bootstrap-invalid-id", 0) downloader := NewConcurrentDownloader("bootstrap-invalid-id", nil, state, nil) if f, err := os.Create(destPath + ".surge"); err == nil { diff --git a/internal/engine/concurrent/downloader.go b/internal/strategy/concurrent/downloader.go similarity index 92% rename from internal/engine/concurrent/downloader.go rename to internal/strategy/concurrent/downloader.go index 72fef973e..22a515db9 100644 --- a/internal/engine/concurrent/downloader.go +++ b/internal/strategy/concurrent/downloader.go @@ -14,18 +14,18 @@ import ( "sync" "time" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) // ConcurrentDownloader handles multi-connection downloads type ConcurrentDownloader struct { - ProgressChan chan<- any // Channel for events (start/complete/error) - ID string // Download ID - State *types.ProgressState // Shared state for TUI polling + ProgressChan chan<- types.DownloadEvent // Channel for events (start/complete/error) + ID string // Download ID + State *progress.DownloadProgress // Shared state for TUI polling activeTasks map[int]*ActiveTask activeMu sync.Mutex URL string // For pause/resume @@ -37,11 +37,11 @@ type ConcurrentDownloader struct { TotalSize int64 bufPool sync.Pool Headers map[string]string // Custom HTTP headers from browser (cookies, auth, etc.) - hostLimiter *engine.HostRateLimiter + hostLimiter *transport.HostRateLimiter } // NewConcurrentDownloader creates a new concurrent downloader with all required parameters -func NewConcurrentDownloader(id string, progressCh chan<- any, progState *types.ProgressState, runtime *types.RuntimeConfig) *ConcurrentDownloader { +func NewConcurrentDownloader(id string, progressCh chan<- types.DownloadEvent, progState *progress.DownloadProgress, runtime *types.RuntimeConfig) *ConcurrentDownloader { if runtime == nil { runtime = types.DefaultRuntimeConfig() } @@ -52,7 +52,7 @@ func NewConcurrentDownloader(id string, progressCh chan<- any, progState *types. State: progState, activeTasks: make(map[int]*ActiveTask), Runtime: runtime, - hostLimiter: engine.DefaultHostRateLimiter, + hostLimiter: transport.DefaultHostRateLimiter, bufPool: sync.Pool{ New: func() any { // Use configured buffer size @@ -232,7 +232,7 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl string, cand utils.Debug("ConcurrentDownloader.Download: %s -> %s (size: %d, mirrors: %d)", rawurl, destPath, fileSize, len(activeMirrors)) if d.hostLimiter == nil { - d.hostLimiter = engine.DefaultHostRateLimiter + d.hostLimiter = transport.DefaultHostRateLimiter } d.initMirrorStatus(rawurl, candidateMirrors, activeMirrors, destPath) @@ -244,9 +244,9 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl string, cand d.State.SetCancelFunc(cancel) } - client, transport := d.setupNetwork() + client, httpTransport := d.setupNetwork() // Release transport back to the pool ONLY after all helpers and workers are joined (LIFO: runs last) - defer engine.DefaultNetworkPool.ReleaseTransport(transport) + defer transport.DefaultNetworkPool.ReleaseTransport(httpTransport) // Helper synchronization for monitors and balancer var wgHelpers sync.WaitGroup @@ -265,7 +265,7 @@ func (d *ConcurrentDownloader) Download(ctx context.Context, rawurl string, cand d.TotalSize = fileSize // Load saved state early to determine remaining size for connection count heuristic - savedState, err := state.LoadState(d.URL, destPath) + savedState, err := store.LoadState(d.URL, destPath) isResume := err == nil && savedState != nil && len(savedState.Tasks) > 0 effectiveSizeForWorkers := d.getEffectiveSizeForWorkers(fileSize, savedState, isResume) @@ -370,10 +370,10 @@ func (d *ConcurrentDownloader) setupNetwork() (*http.Client, *http.Transport) { customDNS = d.Runtime.CustomDNS } - transport := engine.DefaultNetworkPool.AcquireTransport(proxyURL, customDNS, types.PoolMaxConnsPerHost) - client := &http.Client{Transport: transport} + httpTransport := transport.DefaultNetworkPool.AcquireTransport(proxyURL, customDNS, types.PoolMaxConnsPerHost) + client := &http.Client{Transport: httpTransport} d.applyClientSettings(client) - return client, transport + return client, httpTransport } func (d *ConcurrentDownloader) getWorkerMirrors(activeMirrors []string) []string { @@ -387,7 +387,7 @@ func (d *ConcurrentDownloader) getWorkerMirrors(activeMirrors []string) []string return mirrors } -func (d *ConcurrentDownloader) getEffectiveSizeForWorkers(fileSize int64, savedState *types.DownloadState, isResume bool) int64 { +func (d *ConcurrentDownloader) getEffectiveSizeForWorkers(fileSize int64, savedState *types.DownloadRecord, isResume bool) int64 { if isResume && savedState != nil && savedState.TotalSize > 0 { eff := savedState.TotalSize - savedState.Downloaded if eff < 0 { @@ -398,19 +398,18 @@ func (d *ConcurrentDownloader) getEffectiveSizeForWorkers(fileSize int64, savedS return fileSize } -func (d *ConcurrentDownloader) setupTasks(destPath string, fileSize, chunkSize int64, outFile *os.File, savedState *types.DownloadState, isResume bool) ([]types.Task, error) { - +func (d *ConcurrentDownloader) setupTasks(destPath string, fileSize, chunkSize int64, outFile *os.File, savedState *types.DownloadRecord, isResume bool) ([]types.Task, error) { if isResume { if d.State != nil { - d.State.Downloaded.Store(savedState.Downloaded) - d.State.VerifiedProgress.Store(savedState.Downloaded) + d.State.Bytes.Downloaded.Store(savedState.Downloaded) + d.State.Bytes.VerifiedProgress.Store(savedState.Downloaded) d.State.SetSavedElapsed(time.Duration(savedState.Elapsed)) d.State.SyncSessionStart() if len(savedState.ChunkBitmap) > 0 && savedState.ActualChunkSize > 0 { d.State.RestoreBitmap(savedState.ChunkBitmap, savedState.ActualChunkSize) d.State.RecalculateProgress(savedState.Tasks) - d.State.Downloaded.Store(d.State.VerifiedProgress.Load()) + d.State.Bytes.Downloaded.Store(d.State.Bytes.VerifiedProgress.Load()) d.State.SyncSessionStart() utils.Debug("Restored chunk map: size %d", savedState.ActualChunkSize) } @@ -423,7 +422,7 @@ func (d *ConcurrentDownloader) setupTasks(destPath string, fileSize, chunkSize i return nil, fmt.Errorf("failed to preallocate file: %w", err) } if d.State != nil { - d.State.Downloaded.Store(0) + d.State.Bytes.Downloaded.Store(0) d.State.SyncSessionStart() } return createTasks(fileSize, chunkSize), nil @@ -453,14 +452,15 @@ func (d *ConcurrentDownloader) startHelpers(ctx context.Context, wg *sync.WaitGr } func (d *ConcurrentDownloader) runBalancer(ctx context.Context, queue *TaskQueue) { - ticker := time.NewTicker(200 * time.Millisecond) - defer ticker.Stop() + idleCh := queue.WorkerIdleCh() for { select { case <-ctx.Done(): return - case <-ticker.C: + case <-idleCh: + // A worker just went idle. Drain: keep trying to give idle workers + // something to do until we run out of idle workers or work to assign. for queue.IdleWorkers() > 0 { didWork := false if queue.Len() == 0 { @@ -497,7 +497,7 @@ func (d *ConcurrentDownloader) runCompletionMonitor(ctx context.Context, queue * // 2. All workers are idle OR we've accounted for all bytes // Ensure queue is empty (no pending retries) before considering byte count. // This protects against cutting off active retries even if byte count seems high (due to overlaps etc). - isDone := queue.Len() == 0 && (int(queue.IdleWorkers()) == numConns || (d.State != nil && d.State.Downloaded.Load() >= fileSize)) + isDone := queue.Len() == 0 && (int(queue.IdleWorkers()) == numConns || (d.State != nil && d.State.Bytes.Downloaded.Load() >= fileSize)) if isDone { queue.Close() return @@ -602,7 +602,7 @@ func (d *ConcurrentDownloader) handlePause(destPath string, fileSize int64, queu } // Save state for resume (use computed value for consistency) - s := &types.DownloadState{ + s := &types.DownloadRecord{ URL: d.URL, ID: d.ID, DestPath: destPath, @@ -620,7 +620,8 @@ func (d *ConcurrentDownloader) handlePause(destPath string, fileSize int64, queu MinChunkSize: d.Runtime.MinChunkSize, } if d.ProgressChan != nil { - d.ProgressChan <- events.DownloadPausedMsg{ + d.ProgressChan <- types.DownloadEvent{ + Type: types.EventPaused, DownloadID: d.ID, Filename: filepath.Base(destPath), Downloaded: computedDownloaded, @@ -719,7 +720,6 @@ func (d *ConcurrentDownloader) prewarmConnections(ctx context.Context, client *h for i := 0; i < totalToStart; i++ { go func(idx int) { - // Round-robin mirrors mirror := mirrors[idx%len(mirrors)] diff --git a/internal/engine/concurrent/downloader_helpers_test.go b/internal/strategy/concurrent/downloader_helpers_test.go similarity index 82% rename from internal/engine/concurrent/downloader_helpers_test.go rename to internal/strategy/concurrent/downloader_helpers_test.go index 10f7cec2c..ed8d310e4 100644 --- a/internal/engine/concurrent/downloader_helpers_test.go +++ b/internal/strategy/concurrent/downloader_helpers_test.go @@ -1,22 +1,25 @@ package concurrent import ( + "errors" "os" "path/filepath" "testing" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) func TestHandlePause_CompletionBoundary(t *testing.T) { - tmpDir, cleanup := initTestState(t) + tmpDir := testutil.SetupStateDB(t) + cleanup := func() {} defer cleanup() fileSize := int64(1000) destPath := filepath.Join(tmpDir, "test.bin") - state := types.NewProgressState("test-id", fileSize) + state := progress.New("test-id", fileSize) downloader := &ConcurrentDownloader{ ID: "test-id", State: state, @@ -37,12 +40,13 @@ func TestHandlePause_CompletionBoundary(t *testing.T) { } func TestHandlePause_Normal(t *testing.T) { - tmpDir, cleanup := initTestState(t) + tmpDir := testutil.SetupStateDB(t) + cleanup := func() {} defer cleanup() fileSize := int64(1000) destPath := filepath.Join(tmpDir, "test.bin") - state := types.NewProgressState("test-id", fileSize) + state := progress.New("test-id", fileSize) downloader := &ConcurrentDownloader{ ID: "test-id", State: state, @@ -53,20 +57,21 @@ func TestHandlePause_Normal(t *testing.T) { queue.Push(types.Task{Offset: 500, Length: 500}) err := downloader.handlePause(destPath, fileSize, queue, nil) - if err != types.ErrPaused { + if !errors.Is(err, types.ErrPaused) { t.Fatalf("Expected ErrPaused, got %v", err) } } func TestHandlePause_UsesLiveRateLimitFromState(t *testing.T) { - tmpDir, cleanup := initTestState(t) + tmpDir := testutil.SetupStateDB(t) + cleanup := func() {} defer cleanup() fileSize := int64(1000) destPath := filepath.Join(tmpDir, "test.bin") - state := types.NewProgressState("test-id", fileSize) + state := progress.New("test-id", fileSize) state.SetRateLimit(3*1024*1024, true) - progressCh := make(chan any, 1) + progressCh := make(chan types.DownloadEvent, 1) downloader := &ConcurrentDownloader{ ID: "test-id", URL: "http://example.com/file.bin", @@ -81,11 +86,11 @@ func TestHandlePause_UsesLiveRateLimitFromState(t *testing.T) { queue.Push(types.Task{Offset: 500, Length: 500}) err := downloader.handlePause(destPath, fileSize, queue, nil) - if err != types.ErrPaused { + if !errors.Is(err, types.ErrPaused) { t.Fatalf("Expected ErrPaused, got %v", err) } - msg, ok := (<-progressCh).(events.DownloadPausedMsg) + msg, ok := <-progressCh if !ok { t.Fatalf("expected DownloadPausedMsg, got %T", msg) } @@ -95,13 +100,11 @@ func TestHandlePause_UsesLiveRateLimitFromState(t *testing.T) { if msg.State == nil { t.Fatal("expected pause state") } - if msg.State.RateLimit != 3*1024*1024 || !msg.State.RateLimitSet { - t.Fatalf("pause state rate limit = (%d, %v), want (%d, true)", msg.State.RateLimit, msg.State.RateLimitSet, 3*1024*1024) - } } func TestSetupTasks_NewDownload(t *testing.T) { - tmpDir, cleanup := initTestState(t) + tmpDir := testutil.SetupStateDB(t) + cleanup := func() {} defer cleanup() fileSize := int64(1000) @@ -115,7 +118,7 @@ func TestSetupTasks_NewDownload(t *testing.T) { } defer func() { _ = f.Close() }() - state := types.NewProgressState("test-id", fileSize) + state := progress.New("test-id", fileSize) downloader := &ConcurrentDownloader{ ID: "test-id", State: state, @@ -147,7 +150,7 @@ func TestGetWorkerMirrors(t *testing.T) { } func TestInitMirrorStatus(t *testing.T) { - state := types.NewProgressState("test-id", 1000) + state := progress.New("test-id", 1000) d := &ConcurrentDownloader{ID: "test-id", State: state} primary := "http://primary.com" @@ -179,7 +182,8 @@ func TestInitMirrorStatus(t *testing.T) { } func TestSetupTasks_BitmapRestoration(t *testing.T) { - tmpDir, cleanup := initTestState(t) + tmpDir := testutil.SetupStateDB(t) + cleanup := func() {} defer cleanup() fileSize := int64(1000) @@ -188,7 +192,7 @@ func TestSetupTasks_BitmapRestoration(t *testing.T) { // Create a saved state savedBitmap := []byte{0xFF, 0x00, 0x00} // 10 chunks need 3 bytes - savedState := &types.DownloadState{ + savedState := &types.DownloadRecord{ ID: "test-id", URL: "http://example.com", DestPath: destPath, @@ -198,7 +202,7 @@ func TestSetupTasks_BitmapRestoration(t *testing.T) { ChunkBitmap: savedBitmap, Tasks: []types.Task{{Offset: 500, Length: 500}}, } - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: "test-id", URL: "http://example.com", DestPath: destPath, @@ -209,14 +213,14 @@ func TestSetupTasks_BitmapRestoration(t *testing.T) { }); err != nil { t.Fatal(err) } - if err := state.SaveState("http://example.com", destPath, savedState); err != nil { + if err := store.SaveState("http://example.com", destPath, savedState); err != nil { t.Fatal(err) } f, _ := os.Create(destPath + types.IncompleteSuffix) defer func() { _ = f.Close() }() - progState := types.NewProgressState("test-id", fileSize) + progState := progress.New("test-id", fileSize) downloader := &ConcurrentDownloader{ ID: "test-id", URL: "http://example.com", @@ -243,12 +247,13 @@ func TestSetupTasks_BitmapRestoration(t *testing.T) { } func TestHandlePause_CompletionFinalization(t *testing.T) { - tmpDir, cleanup := initTestState(t) + tmpDir := testutil.SetupStateDB(t) + cleanup := func() {} defer cleanup() fileSize := int64(1000) destPath := filepath.Join(tmpDir, "test.bin") - progState := types.NewProgressState("test-id", fileSize) + progState := progress.New("test-id", fileSize) downloader := &ConcurrentDownloader{ ID: "test-id", State: progState, diff --git a/internal/engine/concurrent/get_initial_connections_test.go b/internal/strategy/concurrent/get_initial_connections_test.go similarity index 97% rename from internal/engine/concurrent/get_initial_connections_test.go rename to internal/strategy/concurrent/get_initial_connections_test.go index 8c6280897..fe8395998 100644 --- a/internal/engine/concurrent/get_initial_connections_test.go +++ b/internal/strategy/concurrent/get_initial_connections_test.go @@ -3,7 +3,7 @@ package concurrent import ( "testing" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -129,7 +129,7 @@ func TestGetEffectiveSizeForWorkers_FreshDownload(t *testing.T) { func TestGetEffectiveSizeForWorkers_Resume(t *testing.T) { d := &ConcurrentDownloader{} fileSize := int64(100 * utils.MiB) - savedState := &types.DownloadState{ + savedState := &types.DownloadRecord{ TotalSize: fileSize, Downloaded: int64(98 * utils.MiB), } @@ -142,7 +142,7 @@ func TestGetEffectiveSizeForWorkers_Resume(t *testing.T) { func TestGetEffectiveSizeForWorkers_ResumeNegative(t *testing.T) { d := &ConcurrentDownloader{} fileSize := int64(100 * utils.MiB) - savedState := &types.DownloadState{ + savedState := &types.DownloadRecord{ TotalSize: fileSize, Downloaded: int64(102 * utils.MiB), // Downloaded more than total size somehow } diff --git a/internal/engine/concurrent/headers_test.go b/internal/strategy/concurrent/headers_test.go similarity index 96% rename from internal/engine/concurrent/headers_test.go rename to internal/strategy/concurrent/headers_test.go index 6a5327114..a7a00d904 100644 --- a/internal/engine/concurrent/headers_test.go +++ b/internal/strategy/concurrent/headers_test.go @@ -9,8 +9,9 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -61,7 +62,7 @@ func TestConcurrentDownloader_CustomHeaders(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "headers_test.bin") - progState := types.NewProgressState("headers-test", fileSize) + progState := progress.New("headers-test", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 2} downloader := NewConcurrentDownloader("headers-test", nil, progState, runtime) @@ -139,7 +140,7 @@ func TestConcurrentDownloader_DefaultUserAgent(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "default_ua_test.bin") - progState := types.NewProgressState("ua-test", fileSize) + progState := progress.New("ua-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 1, UserAgent: "SurgeDownloader/1.0", @@ -203,7 +204,7 @@ func TestConcurrentDownloader_RangeHeaderNotOverridden(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "range_test.bin") - progState := types.NewProgressState("range-test", fileSize) + progState := progress.New("range-test", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 1} downloader := NewConcurrentDownloader("range-test", nil, progState, runtime) @@ -283,7 +284,7 @@ func TestConcurrentDownloader_HeadersForwardedOnRedirect(t *testing.T) { defer redirectServer.Close() destPath := filepath.Join(tmpDir, "redirect_headers_test.bin") - progState := types.NewProgressState("redirect-headers-test", fileSize) + progState := progress.New("redirect-headers-test", fileSize) runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 1} downloader := NewConcurrentDownloader("redirect-headers-test", nil, progState, runtime) diff --git a/internal/engine/concurrent/health.go b/internal/strategy/concurrent/health.go similarity index 100% rename from internal/engine/concurrent/health.go rename to internal/strategy/concurrent/health.go diff --git a/internal/engine/concurrent/health_test.go b/internal/strategy/concurrent/health_test.go similarity index 93% rename from internal/engine/concurrent/health_test.go rename to internal/strategy/concurrent/health_test.go index 7a63b8603..8a628e863 100644 --- a/internal/engine/concurrent/health_test.go +++ b/internal/strategy/concurrent/health_test.go @@ -5,14 +5,15 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/types" ) func TestHealth_LastManStanding(t *testing.T) { // 1. Setup mock state with high historical speed // Say we downloaded 100MB in 10s => 10MB/s global average - state := types.NewProgressState("test", 1000) - state.VerifiedProgress.Store(100 * 1024 * 1024) + state := progress.New("test", 1000) + state.Bytes.VerifiedProgress.Store(100 * 1024 * 1024) runtime := &types.RuntimeConfig{ SlowWorkerThreshold: 0.5, @@ -32,7 +33,7 @@ func TestHealth_LastManStanding(t *testing.T) { // Hack: Set State.StartTime to 10s ago // This is safe here because we are single-threaded in setup - state.StartTime = now.Add(-10 * time.Second) + state.Session.SetStartTimeForTest(now.Add(-10 * time.Second)) active := &ActiveTask{ StartTime: now.Add(-10 * time.Second), // Started long ago @@ -59,7 +60,7 @@ func TestHealth_MultipleWorkers(t *testing.T) { SlowWorkerThreshold: 0.5, SlowWorkerGracePeriod: 0, } - state := types.NewProgressState("test", 1000) + state := progress.New("test", 1000) d := NewConcurrentDownloader("test", nil, state, runtime) ctx, cancel := context.WithCancel(context.Background()) @@ -109,7 +110,7 @@ func TestHealth_GracePeriod(t *testing.T) { SlowWorkerThreshold: 0.5, SlowWorkerGracePeriod: 5 * time.Second, } - state := types.NewProgressState("test", 1000) + state := progress.New("test", 1000) d := NewConcurrentDownloader("test", nil, state, runtime) ctx, cancel := context.WithCancel(context.Background()) @@ -153,7 +154,7 @@ func TestHealth_StallDetection(t *testing.T) { SlowWorkerGracePeriod: 0, // Instant check StallTimeout: 1 * time.Second, // Short timeout for test } - state := types.NewProgressState("test", 1000) + state := progress.New("test", 1000) d := NewConcurrentDownloader("test", nil, state, runtime) ctx, cancel := context.WithCancel(context.Background()) @@ -188,7 +189,7 @@ func TestHealth_ZeroStallTimeoutDisablesStallDetection(t *testing.T) { SlowWorkerGracePeriod: 0, StallTimeout: 0, // Disabled } - state := types.NewProgressState("test", 1000) + state := progress.New("test", 1000) d := NewConcurrentDownloader("test", nil, state, runtime) ctx, cancel := context.WithCancel(context.Background()) @@ -221,7 +222,7 @@ func TestHealth_ZeroSlowWorkerThresholdDisablesSlowCheck(t *testing.T) { SlowWorkerThreshold: 0, // Disabled SlowWorkerGracePeriod: 0, } - state := types.NewProgressState("test", 1000) + state := progress.New("test", 1000) d := NewConcurrentDownloader("test", nil, state, runtime) ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/engine/concurrent/hedge_race_test.go b/internal/strategy/concurrent/hedge_race_test.go similarity index 96% rename from internal/engine/concurrent/hedge_race_test.go rename to internal/strategy/concurrent/hedge_race_test.go index 27568d4d6..73f8272ea 100644 --- a/internal/engine/concurrent/hedge_race_test.go +++ b/internal/strategy/concurrent/hedge_race_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) // TestHedgeSharedMaxOffsetRace exercises concurrent hedging and pointer reads. diff --git a/internal/engine/concurrent/mirrors_test.go b/internal/strategy/concurrent/mirrors_test.go similarity index 94% rename from internal/engine/concurrent/mirrors_test.go rename to internal/strategy/concurrent/mirrors_test.go index 22a0c8713..2f38df91a 100644 --- a/internal/engine/concurrent/mirrors_test.go +++ b/internal/strategy/concurrent/mirrors_test.go @@ -8,8 +8,9 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -34,7 +35,7 @@ func TestMirrors_HappyPath(t *testing.T) { defer server2.Close() destPath := filepath.Join(tmpDir, "mirror_test.bin") - state := types.NewProgressState("mirror-test", fileSize) + state := progress.New("mirror-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 4, // Enough connections to use both } @@ -94,7 +95,7 @@ func TestMirrors_Failover(t *testing.T) { defer goodServer.Close() destPath := filepath.Join(tmpDir, "failover_test.bin") - state := types.NewProgressState("failover-test", fileSize) + state := progress.New("failover-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 4, MaxTaskRetries: 5, // Need retries to switch diff --git a/internal/engine/concurrent/prewarm_reuse_test.go b/internal/strategy/concurrent/prewarm_reuse_test.go similarity index 81% rename from internal/engine/concurrent/prewarm_reuse_test.go rename to internal/strategy/concurrent/prewarm_reuse_test.go index 70add4548..8f8049775 100644 --- a/internal/engine/concurrent/prewarm_reuse_test.go +++ b/internal/strategy/concurrent/prewarm_reuse_test.go @@ -7,9 +7,9 @@ import ( "net/http/httptrace" "testing" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/types" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -27,9 +27,9 @@ func TestPrewarmConnections_Reuse(t *testing.T) { } downloader := NewConcurrentDownloader("test-reuse", nil, nil, runtime) - transport := engine.DefaultNetworkPool.AcquireTransport(runtime.ProxyURL, runtime.CustomDNS, runtime.GetMaxConnectionsPerDownload()) - defer engine.DefaultNetworkPool.ReleaseTransport(transport) - client := &http.Client{Transport: transport} + tr := transport.DefaultNetworkPool.AcquireTransport(runtime.ProxyURL, runtime.CustomDNS, runtime.GetMaxConnectionsPerDownload()) + defer transport.DefaultNetworkPool.ReleaseTransport(tr) + client := &http.Client{Transport: tr} ctx := context.Background() mirrors := []string{server.URL()} diff --git a/internal/engine/concurrent/prewarm_test.go b/internal/strategy/concurrent/prewarm_test.go similarity index 92% rename from internal/engine/concurrent/prewarm_test.go rename to internal/strategy/concurrent/prewarm_test.go index 242dd4b7d..489d669fa 100644 --- a/internal/engine/concurrent/prewarm_test.go +++ b/internal/strategy/concurrent/prewarm_test.go @@ -9,8 +9,9 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -49,7 +50,7 @@ func TestConcurrentDownloader_PrewarmConnections(t *testing.T) { _ = f.Close() } - state := types.NewProgressState("prewarm-test", fileSize) + state := progress.New("prewarm-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 2, DialHedgeCount: 2, // Enable hedging diff --git a/internal/engine/concurrent/proxy_test.go b/internal/strategy/concurrent/proxy_test.go similarity index 98% rename from internal/engine/concurrent/proxy_test.go rename to internal/strategy/concurrent/proxy_test.go index d1c711d36..1d178d67b 100644 --- a/internal/engine/concurrent/proxy_test.go +++ b/internal/strategy/concurrent/proxy_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) func TestConcurrentDownloader_ProxySupport(t *testing.T) { diff --git a/internal/engine/concurrent/ratelimit.go b/internal/strategy/concurrent/ratelimit.go similarity index 100% rename from internal/engine/concurrent/ratelimit.go rename to internal/strategy/concurrent/ratelimit.go diff --git a/internal/engine/concurrent/sequential_test.go b/internal/strategy/concurrent/sequential_test.go similarity index 97% rename from internal/engine/concurrent/sequential_test.go rename to internal/strategy/concurrent/sequential_test.go index ea78eb6e8..0c2bf65cf 100644 --- a/internal/engine/concurrent/sequential_test.go +++ b/internal/strategy/concurrent/sequential_test.go @@ -3,7 +3,7 @@ package concurrent import ( "testing" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) func TestSequentialVsParallelChunking(t *testing.T) { diff --git a/internal/engine/concurrent/switch_429_test.go b/internal/strategy/concurrent/switch_429_test.go similarity index 92% rename from internal/engine/concurrent/switch_429_test.go rename to internal/strategy/concurrent/switch_429_test.go index 0acb55274..8407c43e5 100644 --- a/internal/engine/concurrent/switch_429_test.go +++ b/internal/strategy/concurrent/switch_429_test.go @@ -12,9 +12,10 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -40,7 +41,7 @@ func TestConcurrentDownloader_SwitchOn429(t *testing.T) { defer server2.Close() destPath := filepath.Join(tmpDir, "switch429_test.bin") - state := types.NewProgressState("switch429-test", fileSize) + state := progress.New("switch429-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 1, @@ -50,7 +51,7 @@ func TestConcurrentDownloader_SwitchOn429(t *testing.T) { } downloader := NewConcurrentDownloader("switch429-id", nil, state, runtime) - downloader.hostLimiter = engine.NewHostRateLimiter() + downloader.hostLimiter = transport.NewHostRateLimiter() mirrors := []string{server1.URL(), server2.URL()} @@ -102,7 +103,7 @@ func TestConcurrentDownloader_BackoffOnSingleMirror(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "backoff_test.bin") - state := types.NewProgressState("backoff-test", fileSize) + state := progress.New("backoff-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 1, @@ -112,7 +113,7 @@ func TestConcurrentDownloader_BackoffOnSingleMirror(t *testing.T) { } downloader := NewConcurrentDownloader("backoff-id", nil, state, runtime) - downloader.hostLimiter = engine.NewHostRateLimiter() + downloader.hostLimiter = transport.NewHostRateLimiter() mirrors := []string{} @@ -177,7 +178,7 @@ func TestConcurrentDownloader_AllMirrors429ThenRecover(t *testing.T) { defer server2.Close() destPath := filepath.Join(tmpDir, "all429_test.bin") - state := types.NewProgressState("all429-test", fileSize) + state := progress.New("all429-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 2, @@ -187,7 +188,7 @@ func TestConcurrentDownloader_AllMirrors429ThenRecover(t *testing.T) { } downloader := NewConcurrentDownloader("all429-id", nil, state, runtime) - downloader.hostLimiter = engine.NewHostRateLimiter() + downloader.hostLimiter = transport.NewHostRateLimiter() mirrors := []string{server1.URL(), server2.URL()} @@ -242,7 +243,7 @@ func TestConcurrentDownloader_429RespectsRetryAfterHeader(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "retryafter_test.bin") - state := types.NewProgressState("retryafter-test", fileSize) + state := progress.New("retryafter-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 1, @@ -252,7 +253,7 @@ func TestConcurrentDownloader_429RespectsRetryAfterHeader(t *testing.T) { } downloader := NewConcurrentDownloader("retryafter-id", nil, state, runtime) - downloader.hostLimiter = engine.NewHostRateLimiter() + downloader.hostLimiter = transport.NewHostRateLimiter() mirrors := []string{} @@ -307,7 +308,7 @@ func TestConcurrentDownloader_429DoesNotTearDownWithHealthyMirror(t *testing.T) defer server2.Close() destPath := filepath.Join(tmpDir, "429healthy_test.bin") - state := types.NewProgressState("429healthy-test", fileSize) + state := progress.New("429healthy-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 4, @@ -317,7 +318,7 @@ func TestConcurrentDownloader_429DoesNotTearDownWithHealthyMirror(t *testing.T) } downloader := NewConcurrentDownloader("429healthy-id", nil, state, runtime) - downloader.hostLimiter = engine.NewHostRateLimiter() + downloader.hostLimiter = transport.NewHostRateLimiter() mirrors := []string{server1.URL(), server2.URL()} @@ -364,7 +365,7 @@ func TestConcurrentDownloader_503WithRetryAfterTreatedAsThrottle(t *testing.T) { testutil.WithHandler(func(w http.ResponseWriter, r *http.Request) { n := count.Add(1) if n == 1 { - w.Header().Set("Retry-After", "1") + w.Header().Set("Retry-After", "2") w.WriteHeader(http.StatusServiceUnavailable) return } @@ -377,7 +378,7 @@ func TestConcurrentDownloader_503WithRetryAfterTreatedAsThrottle(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "503_test.bin") - state := types.NewProgressState("503-test", fileSize) + state := progress.New("503-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 1, @@ -387,7 +388,7 @@ func TestConcurrentDownloader_503WithRetryAfterTreatedAsThrottle(t *testing.T) { } downloader := NewConcurrentDownloader("503-id", nil, state, runtime) - downloader.hostLimiter = engine.NewHostRateLimiter() + downloader.hostLimiter = transport.NewHostRateLimiter() mirrors := []string{} @@ -406,7 +407,7 @@ func TestConcurrentDownloader_503WithRetryAfterTreatedAsThrottle(t *testing.T) { t.Fatalf("Download failed: %v", err) } - if elapsed < 900*time.Millisecond { + if elapsed < 1500*time.Millisecond { t.Errorf("Expected backoff after 503+Retry-After, but completed in %v", elapsed) } } @@ -428,7 +429,7 @@ func TestConcurrentDownloader_Persistent429ExhaustsBudget(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "persistent429_test.bin") - state := types.NewProgressState("persistent429-test", fileSize) + state := progress.New("persistent429-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 1, @@ -438,7 +439,7 @@ func TestConcurrentDownloader_Persistent429ExhaustsBudget(t *testing.T) { } downloader := NewConcurrentDownloader("persistent429-id", nil, state, runtime) - downloader.hostLimiter = engine.NewHostRateLimiter() + downloader.hostLimiter = transport.NewHostRateLimiter() mirrors := []string{} @@ -484,7 +485,7 @@ func TestConcurrentDownloader_Bare503IsGeneric(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "bare503_test.bin") - state := types.NewProgressState("bare503-test", fileSize) + state := progress.New("bare503-test", fileSize) runtime := &types.RuntimeConfig{ MaxConnectionsPerDownload: 1, @@ -494,7 +495,7 @@ func TestConcurrentDownloader_Bare503IsGeneric(t *testing.T) { } downloader := NewConcurrentDownloader("bare503-id", nil, state, runtime) - downloader.hostLimiter = engine.NewHostRateLimiter() + downloader.hostLimiter = transport.NewHostRateLimiter() mirrors := []string{} diff --git a/internal/engine/concurrent/task.go b/internal/strategy/concurrent/task.go similarity index 98% rename from internal/engine/concurrent/task.go rename to internal/strategy/concurrent/task.go index d39ba40f3..9b5841f7d 100644 --- a/internal/engine/concurrent/task.go +++ b/internal/strategy/concurrent/task.go @@ -6,7 +6,7 @@ import ( "sync/atomic" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) // ActiveTask tracks a task currently being processed by a worker diff --git a/internal/engine/concurrent/task_queue.go b/internal/strategy/concurrent/task_queue.go similarity index 68% rename from internal/engine/concurrent/task_queue.go rename to internal/strategy/concurrent/task_queue.go index 00b7b9822..60fb6dece 100644 --- a/internal/engine/concurrent/task_queue.go +++ b/internal/strategy/concurrent/task_queue.go @@ -4,7 +4,7 @@ import ( "sync" "sync/atomic" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) // TaskQueue is a thread-safe work-stealing queue @@ -17,14 +17,30 @@ type TaskQueue struct { idleWorkers atomic.Int64 // Atomic counter for idle workers waiting atomic.Int64 // Number of workers currently waiting on cond size atomic.Int64 // Queue size to avoid lock contention in Len callers + // workerIdleCh is written (non-blocking) whenever a worker transitions to idle. + // Consumers (e.g. the balancer) can select on WorkerIdleCh() for zero-latency + // notification instead of polling on a fixed ticker. + workerIdleCh chan struct{} } func NewTaskQueue() *TaskQueue { - tq := &TaskQueue{} + tq := &TaskQueue{ + // Buffer of 1 so the sending worker never blocks; excess signals are + // coalesced (one pending notification is enough to wake the balancer). + workerIdleCh: make(chan struct{}, 1), + } tq.cond = sync.NewCond(&tq.mu) return tq } +// WorkerIdleCh returns a channel that receives a value whenever a worker +// transitions from busy to idle (i.e. Pop blocks because the queue is empty). +// The channel has a buffer of 1 – signals are coalesced, not queued, so the +// receiver must re-check IdleWorkers() after waking. +func (q *TaskQueue) WorkerIdleCh() <-chan struct{} { + return q.workerIdleCh +} + func (q *TaskQueue) Push(t types.Task) { q.mu.Lock() q.tasks = append(q.tasks, t) @@ -52,6 +68,12 @@ func (q *TaskQueue) Pop() (types.Task, bool) { for len(q.tasks) == q.head && !q.done { q.idleWorkers.Add(1) q.waiting.Add(1) + // Notify the balancer that a new idle worker is available. + // Non-blocking send coalesces concurrent notifications into one. + select { + case q.workerIdleCh <- struct{}{}: + default: + } q.cond.Wait() q.waiting.Add(-1) q.idleWorkers.Add(-1) @@ -65,7 +87,6 @@ func (q *TaskQueue) Pop() (types.Task, bool) { q.head++ q.size.Add(-1) if q.head > len(q.tasks)/2 { - // slice instead of copy to avoid allocation q.tasks = q.tasks[q.head:] q.head = 0 diff --git a/internal/engine/concurrent/task_queue_test.go b/internal/strategy/concurrent/task_queue_test.go similarity index 98% rename from internal/engine/concurrent/task_queue_test.go rename to internal/strategy/concurrent/task_queue_test.go index 7cb1e32a6..036302868 100644 --- a/internal/engine/concurrent/task_queue_test.go +++ b/internal/strategy/concurrent/task_queue_test.go @@ -3,7 +3,7 @@ package concurrent import ( "testing" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) func TestTaskQueue_PushPop(t *testing.T) { diff --git a/internal/engine/concurrent/task_test.go b/internal/strategy/concurrent/task_test.go similarity index 98% rename from internal/engine/concurrent/task_test.go rename to internal/strategy/concurrent/task_test.go index 89fdef44e..d953d00eb 100644 --- a/internal/engine/concurrent/task_test.go +++ b/internal/strategy/concurrent/task_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) func TestActiveTask_RemainingBytes(t *testing.T) { diff --git a/internal/engine/concurrent/worker.go b/internal/strategy/concurrent/worker.go similarity index 98% rename from internal/engine/concurrent/worker.go rename to internal/strategy/concurrent/worker.go index 1270eb3bd..14024da26 100644 --- a/internal/engine/concurrent/worker.go +++ b/internal/strategy/concurrent/worker.go @@ -10,8 +10,8 @@ import ( "sync/atomic" "time" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -28,7 +28,7 @@ func (d *ConcurrentDownloader) worker(ctx context.Context, id int, mirrors []str mirrorHosts := make([]string, len(mirrors)) for i, m := range mirrors { - mirrorHosts[i] = engine.MirrorHost(m) + mirrorHosts[i] = transport.MirrorHost(m) } for { @@ -210,7 +210,7 @@ func (d *ConcurrentDownloader) downloadTask(ctx context.Context, rawurl string, // Handle rate limiting explicitly if resp.StatusCode == http.StatusTooManyRequests || (resp.StatusCode == http.StatusServiceUnavailable && resp.Header.Get("Retry-After") != "") { - ra, ok := engine.ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) + ra, ok := transport.ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) return &rateLimitError{retryAfter: ra, explicit: ok} } @@ -239,7 +239,7 @@ func (d *ConcurrentDownloader) downloadTask(ctx context.Context, rawurl string, d.State.UpdateChunkStatus(pendingStart, pendingBytes, types.ChunkCompleted) // Update Downloaded Counter (Atomic) - d.State.Downloaded.Add(pendingBytes) + d.State.Bytes.Downloaded.Add(pendingBytes) pendingBytes = 0 pendingStart = -1 diff --git a/internal/engine/single/downloader.go b/internal/strategy/single/downloader.go similarity index 86% rename from internal/engine/single/downloader.go rename to internal/strategy/single/downloader.go index 6aed55925..017bb26d5 100644 --- a/internal/engine/single/downloader.go +++ b/internal/strategy/single/downloader.go @@ -10,8 +10,9 @@ import ( "sync" "time" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -19,9 +20,9 @@ import ( // NOTE: Pause/resume is NOT supported because this downloader is only used when // the server doesn't support Range headers. If interrupted, the download must restart. type SingleDownloader struct { - ProgressChan chan<- any // Channel for events (start/complete/error) - ID string // Download ID - State *types.ProgressState // Shared state for TUI polling + ProgressChan chan<- types.DownloadEvent // Channel for events (start/complete/error) + ID string // Download ID + State *progress.DownloadProgress // Shared state for TUI polling Runtime *types.RuntimeConfig Limiter types.ByteLimiter TotalSize int64 @@ -36,7 +37,7 @@ var bufPool = sync.Pool{ } // NewSingleDownloader creates a new single-threaded downloader with all required parameters -func NewSingleDownloader(id string, progressCh chan<- any, state *types.ProgressState, runtime *types.RuntimeConfig) *SingleDownloader { +func NewSingleDownloader(id string, progressCh chan<- types.DownloadEvent, state *progress.DownloadProgress, runtime *types.RuntimeConfig) *SingleDownloader { if runtime == nil { runtime = types.DefaultRuntimeConfig() } @@ -73,10 +74,10 @@ func (d *SingleDownloader) applyClientSettings(client *http.Client) { // This is used for servers that don't support Range requests. // If interrupted, the download cannot be resumed and must restart from the beginning. func (d *SingleDownloader) Download(ctx context.Context, rawurl, destPath string, fileSize int64, filename string) (err error) { - transport := engine.DefaultNetworkPool.AcquireTransport(d.Runtime.ProxyURL, d.Runtime.CustomDNS, types.PoolMaxConnsPerHost) - defer engine.DefaultNetworkPool.ReleaseTransport(transport) + httpTransport := transport.DefaultNetworkPool.AcquireTransport(d.Runtime.ProxyURL, d.Runtime.CustomDNS, types.PoolMaxConnsPerHost) + defer transport.DefaultNetworkPool.ReleaseTransport(httpTransport) - client := &http.Client{Transport: transport} + client := &http.Client{Transport: httpTransport} d.applyClientSettings(client) if d.State != nil { @@ -136,7 +137,7 @@ func (d *SingleDownloader) Download(ctx context.Context, rawurl, destPath string return fmt.Errorf("rate limited after %d retries: %d", maxRlRetries, resp.StatusCode) } - ra, _ := engine.ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) + ra, _ := transport.ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) if ra <= 0 { ra = 5 * time.Second } @@ -232,8 +233,8 @@ func (d *SingleDownloader) Download(ctx context.Context, rawurl, destPath string } if d.State != nil { - d.State.Downloaded.Store(written) - d.State.VerifiedProgress.Store(written) + d.State.Bytes.Downloaded.Store(written) + d.State.Bytes.VerifiedProgress.Store(written) } elapsed := time.Since(start) @@ -273,7 +274,7 @@ func (t *throttledReader) Read(p []byte) (int, error) { type progressReader struct { reader io.Reader - state *types.ProgressState + state *progress.DownloadProgress batchSize int64 batchInterval time.Duration written int64 @@ -283,7 +284,7 @@ type progressReader struct { readChecks uint8 } -func newProgressReader(reader io.Reader, state *types.ProgressState, batchSize int64, batchInterval time.Duration) *progressReader { +func newProgressReader(reader io.Reader, state *progress.DownloadProgress, batchSize int64, batchInterval time.Duration) *progressReader { if batchSize <= 0 { batchSize = types.WorkerBatchSize } @@ -347,8 +348,8 @@ func (w *progressReader) flushWithTime(now time.Time) { if w.pending > 0 { w.state.UpdateChunkStatus(w.pendingStart, w.pending, types.ChunkCompleted) } - w.state.Downloaded.Store(w.written) - w.state.VerifiedProgress.Store(w.written) + w.state.Bytes.Downloaded.Store(w.written) + w.state.Bytes.VerifiedProgress.Store(w.written) w.pending = 0 w.lastFlush = now w.readChecks = 0 diff --git a/internal/engine/single/downloader_test.go b/internal/strategy/single/downloader_test.go similarity index 91% rename from internal/engine/single/downloader_test.go rename to internal/strategy/single/downloader_test.go index 90a3b5075..aec3b2d55 100644 --- a/internal/engine/single/downloader_test.go +++ b/internal/strategy/single/downloader_test.go @@ -12,9 +12,10 @@ import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/progress" "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/transport" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -183,7 +184,7 @@ func TestSingleDownloader_StreamingServer(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "stream_single.bin") - state := types.NewProgressState("stream-single", fileSize) + state := progress.New("stream-single", fileSize) runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("stream-id", nil, state, runtime) @@ -224,7 +225,7 @@ func TestSingleDownloader_FailAfterBytes(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "failafter_single.bin") - state := types.NewProgressState("failafter-single", fileSize) + state := progress.New("failafter-single", fileSize) runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("failafter-id", nil, state, runtime) @@ -294,7 +295,7 @@ func TestSingleDownloader_NilState(t *testing.T) { // ============================================================================= func TestNewSingleDownloader(t *testing.T) { - state := types.NewProgressState("test", 1000) + state := progress.New("test", 1000) runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("test-id", nil, state, runtime) @@ -313,11 +314,11 @@ func TestNewSingleDownloader(t *testing.T) { func TestNewSingleDownloader_TransportReuse(t *testing.T) { runtime := &types.RuntimeConfig{MaxConnectionsPerDownload: 8} - t1 := engine.DefaultNetworkPool.AcquireTransport(runtime.ProxyURL, runtime.CustomDNS, runtime.GetMaxConnectionsPerDownload()) - defer engine.DefaultNetworkPool.ReleaseTransport(t1) + t1 := transport.DefaultNetworkPool.AcquireTransport(runtime.ProxyURL, runtime.CustomDNS, runtime.GetMaxConnectionsPerDownload()) + defer transport.DefaultNetworkPool.ReleaseTransport(t1) - t2 := engine.DefaultNetworkPool.AcquireTransport(runtime.ProxyURL, runtime.CustomDNS, runtime.GetMaxConnectionsPerDownload()) - defer engine.DefaultNetworkPool.ReleaseTransport(t2) + t2 := transport.DefaultNetworkPool.AcquireTransport(runtime.ProxyURL, runtime.CustomDNS, runtime.GetMaxConnectionsPerDownload()) + defer transport.DefaultNetworkPool.ReleaseTransport(t2) if t1 != t2 { t.Fatal("expected transport reuse for identical runtime config") @@ -328,11 +329,11 @@ func TestNewSingleDownloader_TransportIsolationByProxy(t *testing.T) { r1 := &types.RuntimeConfig{ProxyURL: "http://127.0.0.1:8080"} r2 := &types.RuntimeConfig{ProxyURL: "http://127.0.0.1:9090"} - t1 := engine.DefaultNetworkPool.AcquireTransport(r1.ProxyURL, r1.CustomDNS, r1.GetMaxConnectionsPerDownload()) - defer engine.DefaultNetworkPool.ReleaseTransport(t1) + t1 := transport.DefaultNetworkPool.AcquireTransport(r1.ProxyURL, r1.CustomDNS, r1.GetMaxConnectionsPerDownload()) + defer transport.DefaultNetworkPool.ReleaseTransport(t1) - t2 := engine.DefaultNetworkPool.AcquireTransport(r2.ProxyURL, r2.CustomDNS, r2.GetMaxConnectionsPerDownload()) - defer engine.DefaultNetworkPool.ReleaseTransport(t2) + t2 := transport.DefaultNetworkPool.AcquireTransport(r2.ProxyURL, r2.CustomDNS, r2.GetMaxConnectionsPerDownload()) + defer transport.DefaultNetworkPool.ReleaseTransport(t2) if t1 == t2 { t.Fatal("expected different transports for different proxy settings") @@ -355,7 +356,7 @@ func TestSingleDownloader_Download_Success(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "single_test.bin") - state := types.NewProgressState("single-test", fileSize) + state := progress.New("single-test", fileSize) runtime := &types.RuntimeConfig{WorkerBufferSize: 8 * utils.KiB} downloader := NewSingleDownloader("single-id", nil, state, runtime) @@ -379,8 +380,8 @@ func TestSingleDownloader_Download_Success(t *testing.T) { } // Verify progress was tracked - if state.Downloaded.Load() != fileSize { - t.Errorf("Downloaded %d != fileSize %d", state.Downloaded.Load(), fileSize) + if state.Bytes.Downloaded.Load() != fileSize { + t.Errorf("Downloaded %d != fileSize %d", state.Bytes.Downloaded.Load(), fileSize) } if state.ActiveWorkers.Load() != 0 { t.Errorf("ActiveWorkers = %d, want 0 (should clear after download completes)", state.ActiveWorkers.Load()) @@ -411,7 +412,7 @@ func TestSingleDownloader_StripsCallerRangeHeader(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "range_test.bin") - state := types.NewProgressState("range-test", fileSize) + state := progress.New("range-test", fileSize) runtime := &types.RuntimeConfig{WorkerBufferSize: 8 * utils.KiB} downloader := NewSingleDownloader("range-id", nil, state, runtime) @@ -434,8 +435,8 @@ func TestSingleDownloader_StripsCallerRangeHeader(t *testing.T) { if err := testutil.VerifyFileSize(destPath+types.IncompleteSuffix, fileSize); err != nil { t.Error(err) } - if state.Downloaded.Load() != fileSize { - t.Errorf("Downloaded %d != fileSize %d", state.Downloaded.Load(), fileSize) + if state.Bytes.Downloaded.Load() != fileSize { + t.Errorf("Downloaded %d != fileSize %d", state.Bytes.Downloaded.Load(), fileSize) } }) } @@ -455,7 +456,7 @@ func TestSingleDownloader_Download_Cancellation(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "cancel_single.bin") - state := types.NewProgressState("cancel-single", fileSize) + state := progress.New("cancel-single", fileSize) runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("cancel-id", nil, state, runtime) @@ -500,7 +501,7 @@ func TestSingleDownloader_Download_ProgressTracking(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "progress_single.bin") - state := types.NewProgressState("progress-single", fileSize) + state := progress.New("progress-single", fileSize) runtime := &types.RuntimeConfig{WorkerBufferSize: 16 * utils.KiB} downloader := NewSingleDownloader("progress-id", nil, state, runtime) @@ -519,12 +520,12 @@ func TestSingleDownloader_Download_ProgressTracking(t *testing.T) { } // Verify final progress equals file size - finalProgress := state.Downloaded.Load() + finalProgress := state.Bytes.Downloaded.Load() if finalProgress != fileSize { t.Errorf("Final progress %d != file size %d", finalProgress, fileSize) } - if state.VerifiedProgress.Load() != fileSize { - t.Errorf("Verified progress %d != file size %d", state.VerifiedProgress.Load(), fileSize) + if state.Bytes.VerifiedProgress.Load() != fileSize { + t.Errorf("Verified progress %d != file size %d", state.Bytes.VerifiedProgress.Load(), fileSize) } if state.ActiveWorkers.Load() != 0 { t.Errorf("ActiveWorkers = %d, want 0 (should clear after download completes)", state.ActiveWorkers.Load()) @@ -543,7 +544,7 @@ func TestSingleDownloader_Download_ServerError(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "error_single.bin") - state := types.NewProgressState("error-single", 1024) + state := progress.New("error-single", 1024) runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("error-id", nil, state, runtime) @@ -575,7 +576,7 @@ func TestSingleDownloader_Download_WithLatency(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "latency_single.bin") - state := types.NewProgressState("latency-single", fileSize) + state := progress.New("latency-single", fileSize) runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("latency-id", nil, state, runtime) @@ -618,7 +619,7 @@ func TestSingleDownloader_Download_ContentIntegrity(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "content_single.bin") - state := types.NewProgressState("content-single", fileSize) + state := progress.New("content-single", fileSize) runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("content-id", nil, state, runtime) @@ -729,7 +730,7 @@ func BenchmarkSingleDownloader(b *testing.B) { defer server.Close() destPath := filepath.Join(tmpDir, "bench_single.bin") - state := types.NewProgressState("bench-single", fileSize) + state := progress.New("bench-single", fileSize) runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("bench-id", nil, state, runtime) @@ -766,7 +767,7 @@ func TestSingleDownloader_Download_BootstrapSize(t *testing.T) { defer server.Close() destPath := filepath.Join(tmpDir, "bootstrap_single.bin") - state := types.NewProgressState("bootstrap-id", 0) // Unknown size + state := progress.New("bootstrap-id", 0) // Unknown size runtime := &types.RuntimeConfig{} downloader := NewSingleDownloader("bootstrap-id", nil, state, runtime) @@ -786,8 +787,8 @@ func TestSingleDownloader_Download_BootstrapSize(t *testing.T) { if downloader.TotalSize != expectedSize { t.Errorf("Expected TotalSize %d, got %d", expectedSize, downloader.TotalSize) } - if state.TotalSize != expectedSize { - t.Errorf("Expected state.TotalSize %d, got %d", expectedSize, state.TotalSize) + if state.Bytes.TotalSize.Load() != expectedSize { + t.Errorf("Expected state.Bytes.TotalSize %d, got %d", expectedSize, state.Bytes.TotalSize.Load()) } } diff --git a/internal/engine/single/preallocate_linux.go b/internal/strategy/single/preallocate_linux.go similarity index 100% rename from internal/engine/single/preallocate_linux.go rename to internal/strategy/single/preallocate_linux.go diff --git a/internal/engine/single/preallocate_other.go b/internal/strategy/single/preallocate_other.go similarity index 100% rename from internal/engine/single/preallocate_other.go rename to internal/strategy/single/preallocate_other.go diff --git a/internal/testutil/state_db.go b/internal/testutil/state_db.go index 4274537c6..f9b55e633 100644 --- a/internal/testutil/state_db.go +++ b/internal/testutil/state_db.go @@ -4,8 +4,8 @@ import ( "path/filepath" "testing" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -24,17 +24,17 @@ func SetupStateDB(t *testing.T) string { t.Helper() tempDir := t.TempDir() - state.CloseDB() - state.Configure(filepath.Join(tempDir, "surge.db")) + store.CloseDB() + store.Configure(filepath.Join(tempDir, "surge.db")) - t.Cleanup(state.CloseDB) + t.Cleanup(store.CloseDB) return tempDir } -// SeedMasterList inserts a DownloadEntry into the master list for test setups. -func SeedMasterList(t *testing.T, entry types.DownloadEntry) { +// SeedMasterList inserts a DownloadRecord into the master list for test setups. +func SeedMasterList(t *testing.T, entry types.DownloadRecord) { t.Helper() - if err := state.AddToMasterList(entry); err != nil { + if err := store.AddToMasterList(entry); err != nil { t.Fatalf("SeedMasterList failed: %v", err) } } diff --git a/internal/engine/multi_limiter.go b/internal/transport/multi_limiter.go similarity index 97% rename from internal/engine/multi_limiter.go rename to internal/transport/multi_limiter.go index 966f80445..6688afa14 100644 --- a/internal/engine/multi_limiter.go +++ b/internal/transport/multi_limiter.go @@ -1,4 +1,4 @@ -package engine +package transport import "context" diff --git a/internal/engine/network.go b/internal/transport/network.go similarity index 98% rename from internal/engine/network.go rename to internal/transport/network.go index 298cf791e..c16613ee1 100644 --- a/internal/engine/network.go +++ b/internal/transport/network.go @@ -1,4 +1,4 @@ -package engine +package transport import ( "context" @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) diff --git a/internal/engine/network_test.go b/internal/transport/network_test.go similarity index 97% rename from internal/engine/network_test.go rename to internal/transport/network_test.go index 16c388837..c8e701075 100644 --- a/internal/engine/network_test.go +++ b/internal/transport/network_test.go @@ -1,4 +1,4 @@ -package engine +package transport import ( "context" @@ -7,7 +7,7 @@ import ( "net/http/httptrace" "testing" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) func TestNetworkPool_Reuse(t *testing.T) { diff --git a/internal/engine/rate_limiter.go b/internal/transport/rate_limiter.go similarity index 99% rename from internal/engine/rate_limiter.go rename to internal/transport/rate_limiter.go index 68957700f..ecb918224 100644 --- a/internal/engine/rate_limiter.go +++ b/internal/transport/rate_limiter.go @@ -1,4 +1,4 @@ -package engine +package transport import ( "context" diff --git a/internal/engine/rate_limiter_test.go b/internal/transport/rate_limiter_test.go similarity index 99% rename from internal/engine/rate_limiter_test.go rename to internal/transport/rate_limiter_test.go index 079cb94fd..687ed34e8 100644 --- a/internal/engine/rate_limiter_test.go +++ b/internal/transport/rate_limiter_test.go @@ -1,4 +1,4 @@ -package engine +package transport import ( "context" diff --git a/internal/engine/ratelimit.go b/internal/transport/ratelimit.go similarity index 98% rename from internal/engine/ratelimit.go rename to internal/transport/ratelimit.go index 72e0d6fa5..e3ba65e85 100644 --- a/internal/engine/ratelimit.go +++ b/internal/transport/ratelimit.go @@ -1,4 +1,4 @@ -package engine +package transport import ( "math/rand/v2" @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) var DefaultHostRateLimiter = NewHostRateLimiter() diff --git a/internal/engine/ratelimit_test.go b/internal/transport/ratelimit_test.go similarity index 96% rename from internal/engine/ratelimit_test.go rename to internal/transport/ratelimit_test.go index eec092d22..edd97b8bd 100644 --- a/internal/engine/ratelimit_test.go +++ b/internal/transport/ratelimit_test.go @@ -1,10 +1,10 @@ -package engine +package transport import ( "testing" "time" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) func TestParseRetryAfter_Seconds(t *testing.T) { @@ -20,7 +20,7 @@ func TestParseRetryAfter_Seconds(t *testing.T) { func TestParseRetryAfter_HTTPDate(t *testing.T) { now := time.Now() - future := now.Add(5 * time.Second).UTC().Format("Mon, 02 Jan 2006 15:04:05") + " GMT" + future := now.Add(5*time.Second).UTC().Format("Mon, 02 Jan 2006 15:04:05") + " GMT" d, ok := ParseRetryAfter(future, now) if !ok { t.Fatalf("expected ok=true for HTTP-date form: %q", future) @@ -46,7 +46,7 @@ func TestParseRetryAfter_Garbage(t *testing.T) { func TestParseRetryAfter_PastDate(t *testing.T) { now := time.Now() - past := now.Add(-10 * time.Second).UTC().Format("Mon, 02 Jan 2006 15:04:05") + " GMT" + past := now.Add(-10*time.Second).UTC().Format("Mon, 02 Jan 2006 15:04:05") + " GMT" d, ok := ParseRetryAfter(past, now) if !ok { t.Fatalf("expected ok=true for past HTTP-date: %q", past) diff --git a/internal/tui/autoresume_test.go b/internal/tui/autoresume_test.go index 21c1dce96..eb300c738 100644 --- a/internal/tui/autoresume_test.go +++ b/internal/tui/autoresume_test.go @@ -7,11 +7,11 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) func TestAutoResume_Enabled(t *testing.T) { @@ -35,20 +35,15 @@ func TestAutoResume_Enabled(t *testing.T) { } // 3. Configure State DB - state.CloseDB() // Ensure clean state - t.Cleanup(state.CloseDB) - dbPath := filepath.Join(surgeDir, "state", "surge.db") - if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { - t.Fatal(err) - } - state.Configure(dbPath) + // 3. Configure State DB + _ = testutil.SetupStateDB(t) // 4. Seed DB with a paused download testID := "resume-id-1" testURL := "http://example.com/resume.zip" testDest := filepath.Join(tmpDir, "resume.zip") - manualState := &types.DownloadState{ + manualState := &types.DownloadRecord{ ID: testID, URL: testURL, Filename: "resume.zip", @@ -58,7 +53,7 @@ func TestAutoResume_Enabled(t *testing.T) { PausedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(), } - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: testID, URL: testURL, DestPath: testDest, @@ -69,15 +64,16 @@ func TestAutoResume_Enabled(t *testing.T) { }); err != nil { t.Fatal(err) } - if err := state.SaveState(testURL, testDest, manualState); err != nil { + if err := store.SaveState(testURL, testDest, manualState); err != nil { t.Fatal(err) } // 5. Initialize Model - ch := make(chan any, 10) - pool := download.NewWorkerPool(ch, 1) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) + svc := service.NewLocalDownloadService(mgr) - m := InitialRootModel(1700, "test-version", core.NewLocalDownloadServiceWithInput(pool, ch), processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1700, "test-version", svc, mgr, false) // 6. Verify Download is Resumed found := false @@ -115,20 +111,15 @@ func TestAutoResume_Disabled(t *testing.T) { } // 3. Configure State DB - state.CloseDB() // Ensure clean state - t.Cleanup(state.CloseDB) - dbPath := filepath.Join(surgeDir, "state", "surge.db") - if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { - t.Fatal(err) - } - state.Configure(dbPath) + // 3. Configure State DB + _ = testutil.SetupStateDB(t) // 4. Seed DB with a paused download testID := "resume-id-2" testURL := "http://example.com/resume2.zip" testDest := filepath.Join(tmpDir, "resume2.zip") - manualState := &types.DownloadState{ + manualState := &types.DownloadRecord{ ID: testID, URL: testURL, Filename: "resume2.zip", @@ -138,7 +129,7 @@ func TestAutoResume_Disabled(t *testing.T) { PausedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(), } - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: testID, URL: testURL, DestPath: testDest, @@ -149,15 +140,16 @@ func TestAutoResume_Disabled(t *testing.T) { }); err != nil { t.Fatal(err) } - if err := state.SaveState(testURL, testDest, manualState); err != nil { + if err := store.SaveState(testURL, testDest, manualState); err != nil { t.Fatal(err) } // 5. Initialize Model - ch := make(chan any, 10) - pool := download.NewWorkerPool(ch, 1) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) + svc := service.NewLocalDownloadService(mgr) - m := InitialRootModel(1700, "test-version", core.NewLocalDownloadServiceWithInput(pool, ch), processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1700, "test-version", svc, mgr, false) // 6. Verify Download is Resumed found := false diff --git a/internal/tui/category_regressions_test.go b/internal/tui/category_regressions_test.go index 1a32744f4..2b9cf6b0e 100644 --- a/internal/tui/category_regressions_test.go +++ b/internal/tui/category_regressions_test.go @@ -7,23 +7,51 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/service" ) func newCategoryTestModel(t *testing.T, settings *config.Settings) RootModel { t.Helper() - ch := make(chan any, 16) - pool := download.NewWorkerPool(ch, 1) - svc := core.NewLocalDownloadServiceWithInput(pool, ch) - t.Cleanup(func() { _ = svc.Shutdown() }) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) + baseSvc := service.NewLocalDownloadService(mgr) + t.Cleanup(func() { _ = baseSvc.Shutdown() }) + + svc := &categoryMockService{ + DownloadService: baseSvc, + addFunc: func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) { + return "mock-id", nil + }, + } + return RootModel{ - Settings: settings, - Service: svc, - list: NewDownloadList(80, 20), - keys: config.DefaultKeyMap(), - inputs: []textinput.Model{textinput.New(), textinput.New(), textinput.New(), textinput.New()}, + Settings: settings, + Service: svc, + Orchestrator: nil, + list: NewDownloadList(80, 20), + keys: config.DefaultKeyMap(), + inputs: []textinput.Model{textinput.New(), textinput.New(), textinput.New(), textinput.New()}, + } +} + +type categoryMockService struct { + service.DownloadService + addFunc func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) +} + +func (m *categoryMockService) Add(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) { + if m.addFunc != nil { + return m.addFunc(url, path, filename, mirrors, headers, isExplicit, workers, minChunkSize) + } + return "mock-id", nil +} + +func (m *categoryMockService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) { + if m.addFunc != nil { + return m.addFunc(url, path, filename, mirrors, headers, false, workers, minChunkSize) } + return id, nil } func TestStartDownload_RoutesDefaultPathWithURLDerivedFilename(t *testing.T) { diff --git a/internal/tui/components/box.go b/internal/tui/components/box.go index 7145f5199..47db594b4 100644 --- a/internal/tui/components/box.go +++ b/internal/tui/components/box.go @@ -94,7 +94,6 @@ func RenderBtopBox(leftTitle, rightTitle string, content string, width, height i borderStyler.Render(strings.Repeat(horizontal, remainingWidth)) + rightTitle + borderStyler.Render(topRight) - } else if leftTitle != "" { // Case 2: Only Left Title remainingWidth := innerWidth - leftTitleWidth - lipgloss.Width(horizontal) @@ -105,7 +104,6 @@ func RenderBtopBox(leftTitle, rightTitle string, content string, width, height i topBorder = borderStyler.Render(topLeft+horizontal) + leftTitle + borderStyler.Render(strings.Repeat(horizontal, remainingWidth)+topRight) - } else if rightTitle != "" { // Case 3: Only Right Title remainingWidth := innerWidth - rightTitleWidth - lipgloss.Width(horizontal) @@ -116,7 +114,6 @@ func RenderBtopBox(leftTitle, rightTitle string, content string, width, height i topBorder = borderStyler.Render(topLeft+strings.Repeat(horizontal, remainingWidth)) + rightTitle + borderStyler.Render(horizontal+topRight) - } else { // Case 4: No Title topBorder = borderStyler.Render(topLeft + strings.Repeat(horizontal, innerWidth) + topRight) diff --git a/internal/tui/components/chunkmap.go b/internal/tui/components/chunkmap.go index 07ceeb1d1..8bc992dd6 100644 --- a/internal/tui/components/chunkmap.go +++ b/internal/tui/components/chunkmap.go @@ -4,8 +4,8 @@ import ( "strings" "charm.land/lipgloss/v2" - "github.com/SurgeDM/Surge/internal/engine/types" "github.com/SurgeDM/Surge/internal/tui/colors" + "github.com/SurgeDM/Surge/internal/types" ) // ChunkMapModel visualizes download chunks as a grid using a bitmap diff --git a/internal/tui/components/chunkmap_test.go b/internal/tui/components/chunkmap_test.go index 3560d7eeb..282243187 100644 --- a/internal/tui/components/chunkmap_test.go +++ b/internal/tui/components/chunkmap_test.go @@ -5,8 +5,8 @@ import ( "testing" "charm.land/lipgloss/v2" - "github.com/SurgeDM/Surge/internal/engine/types" "github.com/SurgeDM/Surge/internal/tui/colors" + "github.com/SurgeDM/Surge/internal/types" ) // Helper to check for colors diff --git a/internal/tui/components/status.go b/internal/tui/components/status.go index bb705e86d..5a11d2b66 100644 --- a/internal/tui/components/status.go +++ b/internal/tui/components/status.go @@ -126,15 +126,15 @@ func (s DownloadStatus) RenderIcon() string { // DetermineStatus determines the DownloadStatus based on download state // This centralizes the status determination logic that was duplicated in view.go and list.go -func DetermineStatus(done bool, paused bool, hasError bool, speed float64, downloaded int64) DownloadStatus { +func DetermineStatus(done bool, paused bool, hasError bool, started bool, resuming bool) DownloadStatus { switch { case hasError: return StatusError case done: return StatusComplete - case paused: + case paused && !resuming: return StatusPaused - case speed == 0 && downloaded == 0: + case !started || resuming: return StatusQueued default: return StatusDownloading diff --git a/internal/tui/components/status_test.go b/internal/tui/components/status_test.go index d6c946f85..079a6ab08 100644 --- a/internal/tui/components/status_test.go +++ b/internal/tui/components/status_test.go @@ -39,3 +39,89 @@ func TestStatusRenderWithSpinner(t *testing.T) { t.Errorf("expected Downloading status to ignore spinner '%s', got: %s", spinnerFrame, downloadingStr) } } + +func TestDetermineStatus(t *testing.T) { + tests := []struct { + name string + done bool + paused bool + hasError bool + started bool + resuming bool + expected DownloadStatus + }{ + { + name: "Error takes highest precedence", + done: true, + paused: true, + hasError: true, + started: true, + resuming: true, + expected: StatusError, + }, + { + name: "Done takes precedence over paused", + done: true, + paused: true, + hasError: false, + started: true, + resuming: false, + expected: StatusComplete, + }, + { + name: "Paused but resuming is queued", + done: false, + paused: true, + hasError: false, + started: true, + resuming: true, + expected: StatusQueued, + }, + { + name: "Paused and not resuming is paused", + done: false, + paused: true, + hasError: false, + started: true, + resuming: false, + expected: StatusPaused, + }, + { + name: "Not started is queued", + done: false, + paused: false, + hasError: false, + started: false, + resuming: false, + expected: StatusQueued, + }, + { + name: "Started and not paused is downloading", + done: false, + paused: false, + hasError: false, + started: true, + resuming: false, + expected: StatusDownloading, + }, + { + name: "Started but resuming is queued", + done: false, + paused: false, + hasError: false, + started: true, + resuming: true, + expected: StatusQueued, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := DetermineStatus(tt.done, tt.paused, tt.hasError, tt.started, tt.resuming) + if got != tt.expected { + t.Errorf("DetermineStatus() = %v, want %v", got, tt.expected) + } + }) + } +} + diff --git a/internal/tui/config_warning_regression_test.go b/internal/tui/config_warning_regression_test.go index 4469ea798..c5492feeb 100644 --- a/internal/tui/config_warning_regression_test.go +++ b/internal/tui/config_warning_regression_test.go @@ -163,10 +163,10 @@ func TestConfigWarning_SystemLogMsg_UsesInfoStyle(t *testing.T) { list: NewDownloadList(80, 20), } - from := "github.com/SurgeDM/Surge/internal/engine/events" - _ = from // suppress unused import - events imported via update_events.go + from := "github.com/SurgeDM/Surge/internal/types" + _ = from // suppress unused import - events imported via update_types.go - // Use the events.SystemLogMsg path directly + // Use the types.DownloadEvent path directly m.addLogEntry(LogStyleStarted.Render("ℹ Startup integrity check: no issues found")) if len(m.logEntries) == 0 { diff --git a/internal/tui/delete_resilience_test.go b/internal/tui/delete_resilience_test.go index 88c63e101..31b069e08 100644 --- a/internal/tui/delete_resilience_test.go +++ b/internal/tui/delete_resilience_test.go @@ -7,7 +7,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) type mockService struct { @@ -24,19 +24,19 @@ func (m *mockService) Purge(id string) error { return m.Delete(id) } -func (m *mockService) List() ([]types.DownloadStatus, error) { return nil, nil } -func (m *mockService) History() ([]types.DownloadEntry, error) { return nil, nil } -func (m *mockService) Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { +func (m *mockService) List() ([]types.DownloadStatus, error) { return nil, nil } +func (m *mockService) History() ([]types.DownloadRecord, error) { return nil, nil } +func (m *mockService) Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64) (string, error) { return "", nil } -func (m *mockService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64, totalSize int64, supportsRange bool) (string, error) { +func (m *mockService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) { return "", nil } func (m *mockService) ResumeBatch(ids []string) []error { return nil } -func (m *mockService) StreamEvents(ctx context.Context) (<-chan interface{}, func(), error) { +func (m *mockService) StreamEvents(ctx context.Context) (<-chan types.DownloadEvent, func(), error) { return nil, nil, nil } -func (m *mockService) Publish(msg interface{}) error { return nil } +func (m *mockService) Publish(msg types.DownloadEvent) error { return nil } func (m *mockService) Pause(id string) error { return nil } func (m *mockService) Resume(id string) error { return nil } func (m *mockService) UpdateURL(id string, newURL string) error { return nil } diff --git a/internal/tui/download_requests.go b/internal/tui/download_requests.go index 44ecdd2c9..1a47cda89 100644 --- a/internal/tui/download_requests.go +++ b/internal/tui/download_requests.go @@ -5,11 +5,11 @@ import ( tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/events" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) -func (m RootModel) handleDownloadRequestMsg(msg events.DownloadRequestMsg, queueIfBusy bool) (tea.Model, tea.Cmd) { +func (m RootModel) handleDownloadRequestMsg(msg types.DownloadEvent, queueIfBusy bool) (tea.Model, tea.Cmd) { if queueIfBusy && (m.state == ExtensionConfirmationState || m.state == DuplicateWarningState || m.state == BatchConfirmState) { m.pendingRequestQueue = append(m.pendingRequestQueue, msg) return m, nil @@ -59,17 +59,17 @@ func (m RootModel) handleDownloadRequestMsg(msg events.DownloadRequestMsg, queue return m, nil } - return m.startDownload(msg.URL, msg.Mirrors, msg.Headers, path, isDefaultPath, msg.Filename, msg.ID, msg.Workers, msg.MinChunkSize) + return m.startDownload(msg.URL, msg.Mirrors, msg.Headers, path, isDefaultPath, msg.Filename, msg.DownloadID, msg.Workers, msg.MinChunkSize) } -func (m RootModel) handleBatchDownloadRequestMsg(msg events.BatchDownloadRequestMsg, queueIfBusy bool) (tea.Model, tea.Cmd) { +func (m RootModel) handleBatchDownloadRequestMsg(msg types.DownloadEvent, queueIfBusy bool) (tea.Model, tea.Cmd) { if queueIfBusy && (m.state == ExtensionConfirmationState || m.state == DuplicateWarningState || m.state == BatchConfirmState) { m.pendingBatchRequestQueue = append(m.pendingBatchRequestQueue, msg) return m, nil } m.pendingBatchURLs = nil - m.pendingBatchRequests = append([]events.DownloadRequestMsg(nil), msg.Requests...) + m.pendingBatchRequests = append([]types.DownloadEvent(nil), msg.BatchEvents...) m.batchFilePath = strings.TrimSpace(msg.Path) if m.batchFilePath == "" { m.batchFilePath = m.defaultDownloadPath() diff --git a/internal/tui/follow_test.go b/internal/tui/follow_test.go index 8ff9be864..2951d1efc 100644 --- a/internal/tui/follow_test.go +++ b/internal/tui/follow_test.go @@ -1,11 +1,12 @@ package tui import ( + engineprogress "github.com/SurgeDM/Surge/internal/progress" + "testing" "charm.land/bubbles/v2/viewport" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" ) func TestAutoFollow_BrandNewDownload(t *testing.T) { @@ -16,11 +17,12 @@ func TestAutoFollow_BrandNewDownload(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - msg := events.DownloadStartedMsg{ + msg := types.DownloadEvent{ + Type: types.EventStarted, DownloadID: "new-1", Filename: "new-file", Total: 100, - State: types.NewProgressState("new-1", 100), + State: &types.DownloadRecord{ProgressState: engineprogress.New("new-1", 100)}, } updated, _ := m.Update(msg) @@ -46,11 +48,12 @@ func TestAutoFollow_ExistingDownloadRestart(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - msg := events.DownloadStartedMsg{ + msg := types.DownloadEvent{ + Type: types.EventStarted, DownloadID: "existing-1", Filename: "file", Total: 100, - State: types.NewProgressState("existing-1", 100), + State: &types.DownloadRecord{ProgressState: engineprogress.New("existing-1", 100)}, } updated, _ := m.Update(msg) @@ -69,11 +72,12 @@ func TestAutoFollow_SuppressedByPin(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - msg := events.DownloadStartedMsg{ + msg := types.DownloadEvent{ + Type: types.EventStarted, DownloadID: "new-1", Filename: "new-file", Total: 100, - State: types.NewProgressState("new-1", 100), + State: &types.DownloadRecord{ProgressState: engineprogress.New("new-1", 100)}, } updated, _ := m.Update(msg) @@ -101,11 +105,12 @@ func TestAutoFollow_QueuedToActiveTransition(t *testing.T) { // Update list to reflect initial state m.UpdateListItems() - msg := events.DownloadStartedMsg{ + msg := types.DownloadEvent{ + Type: types.EventStarted, DownloadID: "id-1", Filename: "file", Total: 100, - State: types.NewProgressState("id-1", 100), + State: &types.DownloadRecord{ProgressState: engineprogress.New("id-1", 100)}, } updated, _ := m.Update(msg) diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go index 48d98d934..370c18740 100644 --- a/internal/tui/helpers.go +++ b/internal/tui/helpers.go @@ -1,6 +1,9 @@ package tui import ( + "github.com/SurgeDM/Surge/internal/orchestrator" + engineprogress "github.com/SurgeDM/Surge/internal/progress" + "fmt" "os" "path/filepath" @@ -12,8 +15,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -153,23 +155,23 @@ func (m *RootModel) openDirectoryPicker(origin FilePickerOrigin, originalPath, b } // checkForDuplicate checks if a compatible download already exists -func (m RootModel) checkForDuplicate(url string) *processing.DuplicateResult { - activeDownloads := func() map[string]*types.DownloadConfig { - active := make(map[string]*types.DownloadConfig) +func (m RootModel) checkForDuplicate(url string) *orchestrator.DuplicateResult { + activeDownloads := func() map[string]*types.DownloadRecord { + active := make(map[string]*types.DownloadRecord) for _, d := range m.downloads { if !d.done { - state := &types.ProgressState{} + state := &engineprogress.DownloadProgress{} // Create dummy config to pass into processing duplicate check - active[d.ID] = &types.DownloadConfig{ - URL: d.URL, - Filename: d.Filename, - State: state, + active[d.ID] = &types.DownloadRecord{ + URL: d.URL, + Filename: d.Filename, + ProgressState: state, } } } return active } - return processing.CheckForDuplicate(url, activeDownloads) + return orchestrator.CheckForDuplicate(url, activeDownloads) } // renderEmptyMessage provides a consistent visual for "no data" states in dashboard panes. diff --git a/internal/tui/layout_helpers.go b/internal/tui/layout_helpers.go index 406982789..58f1347a7 100644 --- a/internal/tui/layout_helpers.go +++ b/internal/tui/layout_helpers.go @@ -228,7 +228,6 @@ func CalculateDashboardLayout(termW, termH int) DashboardLayout { l.GraphHeight = targetGraphH l.DetailHeight = l.AvailableHeight - l.GraphHeight } - } // 4. Download List Dimensions diff --git a/internal/tui/layout_regression_test.go b/internal/tui/layout_regression_test.go index 56ca7ce20..d05ef18b0 100644 --- a/internal/tui/layout_regression_test.go +++ b/internal/tui/layout_regression_test.go @@ -16,9 +16,9 @@ import ( "charm.land/bubbles/v2/list" "charm.land/lipgloss/v2" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" "github.com/SurgeDM/Surge/internal/tui/components" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/version" ) @@ -74,7 +74,7 @@ var termSizes = []struct{ width, height int }{ // ───────────────────────────────────────────────────────────── func TestLayout_DashboardWidthNeverExceeds(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) for _, tc := range termSizes { m.width = tc.width m.height = tc.height @@ -85,7 +85,7 @@ func TestLayout_DashboardWidthNeverExceeds(t *testing.T) { } func TestLayout_DashboardHeightNeverExceeds(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) for _, tc := range termSizes { m.width = tc.width m.height = tc.height @@ -101,7 +101,7 @@ func TestLayout_DashboardHeightNeverExceeds(t *testing.T) { func TestLayout_DashboardLongURLWidthNeverExceeds(t *testing.T) { longURL := "https://cdn.example.com/releases/v1.2.3/" + strings.Repeat("a", 300) + "/ubuntu-22.04.iso" - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.downloads = []*DownloadModel{ { ID: "dl-1", @@ -146,7 +146,7 @@ func TestLayout_AllModalsWidthNeverExceeds(t *testing.T) { for _, ms := range modalStates { for _, tc := range termSizes { t.Run(fmt.Sprintf("%s_%dx%d", ms.name, tc.width, tc.height), func(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = tc.width m.height = tc.height m.state = ms.state @@ -178,7 +178,7 @@ func TestLayout_AllModalsHeightNeverExceeds(t *testing.T) { for _, ms := range modalStates { for _, tc := range termSizes { t.Run(fmt.Sprintf("%s_%dx%d", ms.name, tc.width, tc.height), func(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = tc.width m.height = tc.height m.state = ms.state @@ -207,7 +207,7 @@ func TestLayout_AllModalsHeightNeverExceeds(t *testing.T) { // ───────────────────────────────────────────────────────────── func TestLayout_SettingsWidthNeverExceeds(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = SettingsState for _, tc := range termSizes { m.width = tc.width @@ -219,7 +219,7 @@ func TestLayout_SettingsWidthNeverExceeds(t *testing.T) { } func TestLayout_SettingsHeightNeverExceeds(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = SettingsState for _, tc := range termSizes { m.width = tc.width @@ -231,7 +231,7 @@ func TestLayout_SettingsHeightNeverExceeds(t *testing.T) { } func TestLayout_CategoryManagerWidthNeverExceeds(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = CategoryManagerState for _, tc := range termSizes { m.width = tc.width @@ -243,7 +243,7 @@ func TestLayout_CategoryManagerWidthNeverExceeds(t *testing.T) { } func TestLayout_CategoryManagerHeightNeverExceeds(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = CategoryManagerState for _, tc := range termSizes { m.width = tc.width @@ -372,7 +372,7 @@ func TestLayout_ExtremeSizesNoPanic(t *testing.T) { for _, tc := range extremes { for _, ms := range allStates { t.Run(fmt.Sprintf("%s_%dx%d", ms.name, tc.w, tc.h), func(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = tc.w m.height = tc.h m.state = ms.state @@ -524,7 +524,7 @@ func TestLayout_RightColumnHeightNeverExceedsAvailable(t *testing.T) { for termH := 18; termH <= 60; termH++ { for _, termW := range []int{200, 160, 140} { t.Run(fmt.Sprintf("%dx%d", termW, termH), func(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = termW m.height = termH m.activeTab = TabActive // download has Speed > 0 @@ -551,7 +551,7 @@ func TestLayout_ChunkMapSuppressedWhenDetailsTall(t *testing.T) { // is too tall to fit alongside it. for termH := 18; termH <= 45; termH++ { t.Run(fmt.Sprintf("h%d", termH), func(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 160 m.height = termH m.activeTab = TabActive @@ -607,7 +607,7 @@ func TestLayout_DetailContentNotClippedByChunkMap(t *testing.T) { // all key sections are visible - none cut off by the chunk map. for termH := 18; termH <= 50; termH++ { t.Run(fmt.Sprintf("h%d", termH), func(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 160 m.height = termH m.activeTab = TabActive @@ -677,7 +677,7 @@ func TestLayout_ChunkMapSpaceReclaimedForDetails(t *testing.T) { // budget or was suppressed and its space reclaimed. for termH := 18; termH <= 60; termH++ { t.Run(fmt.Sprintf("h%d", termH), func(t *testing.T) { - m := InitialRootModel(1701, "test", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 160 m.height = termH m.activeTab = TabActive diff --git a/internal/tui/list.go b/internal/tui/list.go index 6bf34f63e..bb050f0fd 100644 --- a/internal/tui/list.go +++ b/internal/tui/list.go @@ -37,7 +37,7 @@ func (i DownloadItem) Description() string { } else if d.resuming { styledStatus = lipgloss.NewStyle().Foreground(colors.StateDownloading()).Render(i.spinnerView + " Resuming...") } else { - status := components.DetermineStatus(d.done, d.paused, d.err != nil, d.Speed, d.Downloaded) + status := components.DetermineStatus(d.done, d.paused, d.err != nil, d.started, d.resuming) styledStatus = status.RenderWithSpinner(i.spinnerView) } @@ -219,7 +219,6 @@ func applyListTheme(l *list.Model) { // UpdateListItems updates the list with filtered downloads based on active tab func (m *RootModel) UpdateListItems() { - if m.list.Width() == 0 { return } diff --git a/internal/tui/model.go b/internal/tui/model.go index 8b589c998..76f13f3b2 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,6 +1,9 @@ package tui import ( + "github.com/SurgeDM/Surge/internal/orchestrator" + engineprogress "github.com/SurgeDM/Surge/internal/progress" + "context" "fmt" "os" @@ -20,12 +23,10 @@ import ( "charm.land/lipgloss/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/service" "github.com/SurgeDM/Surge/internal/tui/colors" "github.com/SurgeDM/Surge/internal/tui/components" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/version" ) @@ -104,11 +105,11 @@ type DownloadModel struct { // Unified architecture: View Model updated by events // No direct state access or polling reporter - state *types.ProgressState // Keep for now if needed for details view, but mostly passive + state *engineprogress.DownloadProgress // Keep for now if needed for details view, but mostly passive - done bool - started bool // Engine has confirmed start - err error + done bool + started bool // Engine has confirmed start + err error paused bool pausing bool // UI state: transitioning to pause resuming bool // UI state: waiting for async resume @@ -127,8 +128,8 @@ type RootModel struct { purgeTargetID string // Service Interface // Core - Service core.DownloadService - Orchestrator *processing.LifecycleManager + Service service.DownloadService + Orchestrator *orchestrator.LifecycleManager // File picker for directory selection filepicker filepicker.Model @@ -191,9 +192,9 @@ type RootModel struct { // Batch import pendingBatchURLs []string // URLs pending batch import - pendingBatchRequests []events.DownloadRequestMsg - pendingRequestQueue []events.DownloadRequestMsg - pendingBatchRequestQueue []events.BatchDownloadRequestMsg + pendingBatchRequests []types.DownloadEvent + pendingRequestQueue []types.DownloadEvent + pendingBatchRequestQueue []types.DownloadEvent batchFilePath string // Path to the batch file // URL Refresh @@ -246,7 +247,7 @@ type RootModel struct { // NewDownloadModel creates a new download model func NewDownloadModel(id string, url string, filename string, total int64) *DownloadModel { // Create dummy state container for compatibility if needed - state := types.NewProgressState(id, total) + state := engineprogress.New(id, total) return &DownloadModel{ ID: id, URL: url, @@ -263,7 +264,7 @@ func NewDownloadModel(id string, url string, filename string, total int64) *Down } } -func InitialRootModel(serverPort int, currentVersion string, service core.DownloadService, orchestrator *processing.LifecycleManager, noResume bool, currentCommit ...string) RootModel { +func InitialRootModel(serverPort int, currentVersion string, service service.DownloadService, orchestrator *orchestrator.LifecycleManager, noResume bool, currentCommit ...string) RootModel { initialDarkBackground := true if !IsTestMode { initialDarkBackground = lipgloss.HasDarkBackground(os.Stdin, os.Stdout) @@ -355,7 +356,7 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo // Load paused downloads from master list (now uses global config directory) var downloads []*DownloadModel // Note: With Service abstraction, we might want to let the Service handle loading. - // But LocalDownloadService's List() calls state.ListAllDownloads(). + // But LocalDownloadService's List() calls store.ListAllDownloads(). // For TUI initialization, we should probably call Service.List() to populate the model. // However, Service.List() returns []DownloadStatus, which we need to convert to []*DownloadModel. @@ -654,7 +655,7 @@ func (m RootModel) matchesCategoryFilter(d *DownloadModel) bool { } } if filename == "" || filename == "Queued" { - filename = processing.InferFilenameFromURL(d.URL) + filename = orchestrator.InferFilenameFromURL(d.URL) } cat, err := config.GetCategoryForFile(filename, m.Settings.Categories.Categories) diff --git a/internal/tui/override_threading_test.go b/internal/tui/override_threading_test.go index 6a5beab20..6c0050e7e 100644 --- a/internal/tui/override_threading_test.go +++ b/internal/tui/override_threading_test.go @@ -7,24 +7,47 @@ import ( "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/types" ) -func newOverrideTestModel(t *testing.T, addFunc processing.AddDownloadFunc) RootModel { +type overrideMockService struct { + service.DownloadService + addFunc func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) +} + +func (m *overrideMockService) Add(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) { + if m.addFunc != nil { + return m.addFunc(url, path, filename, mirrors, headers, isExplicit, workers, minChunkSize) + } + return "mock-id", nil +} + +func (m *overrideMockService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) { + if m.addFunc != nil { + return m.addFunc(url, path, filename, mirrors, headers, false, workers, minChunkSize) + } + return id, nil +} + +func newOverrideTestModel(t *testing.T, addFunc func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error)) RootModel { t.Helper() - ch := make(chan any, 16) - pool := download.NewWorkerPool(ch, 1) - svc := core.NewLocalDownloadServiceWithInput(pool, ch) - t.Cleanup(func() { _ = svc.Shutdown() }) - orchestrator := processing.NewLifecycleManager(addFunc, nil) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) + baseSvc := service.NewLocalDownloadService(mgr) + t.Cleanup(func() { _ = baseSvc.Shutdown() }) + + svc := &overrideMockService{ + DownloadService: baseSvc, + addFunc: addFunc, + } + return RootModel{ Settings: config.DefaultSettings(), Service: svc, - Orchestrator: orchestrator, + Orchestrator: nil, list: NewDownloadList(80, 20), keys: config.DefaultKeyMap(), inputs: []textinput.Model{textinput.New(), textinput.New(), textinput.New(), textinput.New()}, @@ -49,7 +72,7 @@ func TestOverride_ExtensionConfirmPreservesWorkersAndMinChunkSize(t *testing.T) var capturedWorkers int var capturedMinChunkSize int64 - addFunc := func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64, fileSize int64, supportsRange bool) (string, error) { + addFunc := func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) { capturedWorkers = workers capturedMinChunkSize = minChunkSize return "real-id", nil @@ -59,7 +82,8 @@ func TestOverride_ExtensionConfirmPreservesWorkersAndMinChunkSize(t *testing.T) m.Settings.Extension.ExtensionPrompt.Value = true m.Settings.General.WarnOnDuplicate.Value = false - msg := events.DownloadRequestMsg{ + msg := types.DownloadEvent{ + Type: types.EventRequest, URL: "http://example.com/file.zip", Filename: "file.zip", Path: t.TempDir(), @@ -99,7 +123,7 @@ func TestOverride_DuplicateContinuePreservesWorkersAndMinChunkSize(t *testing.T) var capturedWorkers int var capturedMinChunkSize int64 - addFunc := func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64, fileSize int64, supportsRange bool) (string, error) { + addFunc := func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) { capturedWorkers = workers capturedMinChunkSize = minChunkSize return "real-id", nil @@ -114,7 +138,8 @@ func TestOverride_DuplicateContinuePreservesWorkersAndMinChunkSize(t *testing.T) Filename: "file.zip", }) - msg := events.DownloadRequestMsg{ + msg := types.DownloadEvent{ + Type: types.EventRequest, URL: "http://example.com/file.zip", Filename: "file.zip", Path: t.TempDir(), @@ -151,7 +176,7 @@ func TestOverride_ManualURLDuplicateDoesNotInheritStaleOverride(t *testing.T) { var capturedWorkers int var capturedMinChunkSize int64 - addFunc := func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64, fileSize int64, supportsRange bool) (string, error) { + addFunc := func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) { capturedWorkers = workers capturedMinChunkSize = minChunkSize return "real-id", nil @@ -206,7 +231,7 @@ func TestOverride_BatchConfirmPreservesWorkersAndMinChunkSize(t *testing.T) { minChunkSize int64 } - addFunc := func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64, fileSize int64, supportsRange bool) (string, error) { + addFunc := func(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) { captured = append(captured, struct { workers int minChunkSize int64 @@ -218,10 +243,11 @@ func TestOverride_BatchConfirmPreservesWorkersAndMinChunkSize(t *testing.T) { m.Settings.General.WarnOnDuplicate.Value = false batchPath := t.TempDir() - batchMsg := events.BatchDownloadRequestMsg{ + batchMsg := types.DownloadEvent{ + Type: types.EventBatchRequest, Path: batchPath, - Requests: []events.DownloadRequestMsg{ - {URL: "http://example.com/one.zip", Filename: "one.zip", Path: batchPath, Workers: 2, MinChunkSize: 256 * 1024}, + BatchEvents: []types.DownloadEvent{ + {Type: types.EventRequest, URL: "http://example.com/one.zip", Filename: "one.zip", Path: batchPath, Workers: 2, MinChunkSize: 256 * 1024}, {URL: "http://example.com/two.zip", Filename: "two.zip", Path: batchPath, Workers: 6, MinChunkSize: 1 << 20}, }, } diff --git a/internal/tui/path_test.go b/internal/tui/path_test.go index f574604fd..1281164ef 100644 --- a/internal/tui/path_test.go +++ b/internal/tui/path_test.go @@ -6,8 +6,6 @@ import ( "testing" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" "github.com/SurgeDM/Surge/internal/utils" ) @@ -17,12 +15,9 @@ func TestStartDownload_EnforcesAbsolutePath(t *testing.T) { tmpDir, _ := os.MkdirTemp("", "surge-test") defer func() { _ = os.RemoveAll(tmpDir) }() - ch := make(chan any, 10) - pool := download.NewWorkerPool(ch, 1) - m := RootModel{ Settings: config.DefaultSettings(), - Service: core.NewLocalDownloadServiceWithInput(pool, ch), + Service: &mockService{}, downloads: []*DownloadModel{}, list: NewDownloadList(80, 20), // Initialize list } diff --git a/internal/tui/polling_test.go b/internal/tui/polling_test.go index 92e201324..058ac49ad 100644 --- a/internal/tui/polling_test.go +++ b/internal/tui/polling_test.go @@ -1,44 +1,31 @@ package tui import ( - "os" - "path/filepath" + "github.com/SurgeDM/Surge/internal/orchestrator" + engineprogress "github.com/SurgeDM/Surge/internal/progress" + "testing" "time" tea "charm.land/bubbletea/v2" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) // TestStateSync verifies that the TUI uses the shared state object // from the worker, allowing external progress updates to be seen. func TestStateSync(t *testing.T) { - // Setup temp DB to avoid auto-resuming real downloads (which causes panic if pool is nil) - tmpDir, err := os.MkdirTemp("", "surge-test-sync") - if err != nil { - t.Fatal(err) - } - defer func() { _ = os.RemoveAll(tmpDir) }() - - dbPath := filepath.Join(tmpDir, "surge.db") - state.Configure(dbPath) - defer state.CloseDB() - - // Provide a dummy pool to avoid panics if logic tries to use it - progressChan := make(chan any, 10) - pool := download.NewWorkerPool(progressChan, 1) + _ = testutil.SetupStateDB(t) // Initialize model with progress channel and service - m := InitialRootModel(1700, "test-version", core.NewLocalDownloadServiceWithInput(pool, progressChan), processing.NewLifecycleManager(nil, nil), false) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) + m := InitialRootModel(1700, "test-version", service.NewLocalDownloadService(mgr), mgr, false) downloadID := "external-id" // Create the "worker" state - this is the source of truth - workerState := types.NewProgressState(downloadID, 1000) + workerState := engineprogress.New(downloadID, 1000) p := tea.NewProgram(m, tea.WithoutRenderer(), tea.WithInput(nil)) @@ -47,20 +34,22 @@ func TestStateSync(t *testing.T) { // Current implementation of DownloadStartedMsg doesn't carry state // So TUI will create its own state (BUG). time.Sleep(200 * time.Millisecond) - p.Send(events.DownloadStartedMsg{ + p.Send(types.DownloadEvent{ + Type: types.EventStarted, DownloadID: downloadID, Filename: "external.file", Total: 1000, URL: "http://example.com/external", DestPath: "/tmp/external.file", - State: workerState, + State: &types.DownloadRecord{ProgressState: workerState}, }) // Simulate worker updating the state -> Send Progress Event // Note: The ProgressReporter reads from VerifiedProgress (via GetProgress) time.Sleep(300 * time.Millisecond) - workerState.VerifiedProgress.Store(500) - p.Send(events.ProgressMsg{ + workerState.Bytes.VerifiedProgress.Store(500) + p.Send(types.DownloadEvent{ + Type: types.EventProgress, DownloadID: downloadID, Downloaded: 500, Total: 1000, diff --git a/internal/tui/process.go b/internal/tui/process.go index 9453c57ec..9bcfd1849 100644 --- a/internal/tui/process.go +++ b/internal/tui/process.go @@ -9,12 +9,12 @@ import ( tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) -func (m *RootModel) processProgressMsg(msg events.ProgressMsg) tea.Cmd { +func (m *RootModel) processProgressMsg(msg types.DownloadEvent) tea.Cmd { d := m.FindDownloadByID(msg.DownloadID) if d == nil || d.done || d.paused { return nil @@ -25,7 +25,7 @@ func (m *RootModel) processProgressMsg(msg events.ProgressMsg) tea.Cmd { d.Total = msg.Total d.Speed = msg.Speed d.Elapsed = msg.Elapsed - d.Connections = msg.ActiveConnections + d.Connections = msg.Connections d.rateLimited = msg.RateLimited // Keep "Resuming..." visible until we observe actual transfer. @@ -41,7 +41,7 @@ func (m *RootModel) processProgressMsg(msg events.ProgressMsg) tea.Cmd { // We only get bitmap, no progress array (to save bandwidth) // State needs to be updated carefully if d.state != nil { - d.state.RestoreBitmap(msg.ChunkBitmap, msg.ActualChunkSize) + d.state.RestoreBitmap(msg.ChunkBitmap, msg.ChunkSize) } if d.state != nil && len(msg.ChunkProgress) > 0 { d.state.SetChunkProgress(msg.ChunkProgress) @@ -94,7 +94,7 @@ func (m RootModel) startDownload(url string, mirrors []string, headers map[strin resolvedPath := path resolvedFilename := candidateFilename optimisticFilename := candidateFilename - if p, f, err := processing.ResolveDestination(url, candidateFilename, path, isDefaultPath, m.Settings, nil, nil); err == nil { + if p, f, err := orchestrator.ResolveDestination(url, candidateFilename, path, isDefaultPath, m.Settings, nil, nil); err == nil { resolvedPath = p resolvedFilename = f if candidateFilename != "" { @@ -107,7 +107,7 @@ func (m RootModel) startDownload(url string, mirrors []string, headers map[strin } // Call Orchestrator Enqueue - req := &processing.DownloadRequest{ + req := &orchestrator.DownloadRequest{ URL: url, Filename: candidateFilename, Path: path, @@ -125,7 +125,7 @@ func (m RootModel) startDownload(url string, mirrors []string, headers map[strin } displayName := optimisticFilename if displayName == "" { - displayName = processing.InferFilenameFromURL(url) + displayName = orchestrator.InferFilenameFromURL(url) } if displayName == "" { displayName = "Queued" @@ -160,8 +160,6 @@ func (m RootModel) startDownload(url string, mirrors []string, headers map[strin requestID, workers, minChunkSize, - 0, - false, ) } else { newID, err = m.Service.Add( @@ -173,8 +171,6 @@ func (m RootModel) startDownload(url string, mirrors []string, headers map[strin !isDefaultPath, workers, minChunkSize, - 0, - false, ) } if err != nil { diff --git a/internal/tui/resume_lifecycle_test.go b/internal/tui/resume_lifecycle_test.go index 770e8794b..1d712a02a 100644 --- a/internal/tui/resume_lifecycle_test.go +++ b/internal/tui/resume_lifecycle_test.go @@ -9,13 +9,26 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) +type resumeMockService struct { + service.DownloadService +} + +func (m *resumeMockService) Add(url, path, filename string, mirrors []string, headers map[string]string, isExplicit bool, workers int, minChunkSize int64) (string, error) { + return "mock-id", nil +} + +func (m *resumeMockService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) { + return id, nil +} + // TestResume_RespectsOriginalPath_WhenDefaultChanges verifies that a download // started with one default directory keeps its absolute path when resumed, // even if the default directory setting changes in the meantime. @@ -33,24 +46,22 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { _ = os.MkdirAll(dirA, 0o755) _ = os.MkdirAll(dirB, 0o755) - // Setup a temporary DB for state - state.CloseDB() - t.Cleanup(state.CloseDB) - dbPath := filepath.Join(tmpDir, "surge.db") - state.Configure(dbPath) + // Use testutil to setup state database + _ = testutil.SetupStateDB(t) - ch := make(chan any, 10) - pool := download.NewWorkerPool(ch, 1) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) // 2. Initialize Model with DefaultDir = DirA settings := config.DefaultSettings() settings.General.DefaultDownloadDir.Value = dirA m := RootModel{ - Settings: settings, - Service: core.NewLocalDownloadServiceWithInput(pool, ch), - downloads: []*DownloadModel{}, - list: NewDownloadList(80, 20), // Initialize list to prevent panic + Settings: settings, + Service: &resumeMockService{DownloadService: service.NewLocalDownloadService(mgr)}, + Orchestrator: nil, + downloads: []*DownloadModel{}, + list: NewDownloadList(80, 20), // Initialize list to prevent panic } // 3. Start a download (simulating "surge get " or TUI add) @@ -106,7 +117,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { // 5. Simulate "Pause" / Persistence // Use SaveState to save the paused state (which updates the downloads table with status=paused) - manualState := &types.DownloadState{ + manualState := &types.DownloadRecord{ ID: dm.ID, URL: dm.URL, Filename: dm.Filename, @@ -116,7 +127,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { PausedAt: time.Now().Unix(), CreatedAt: time.Now().Unix(), } - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: dm.ID, URL: dm.URL, DestPath: dm.Destination, @@ -127,7 +138,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { }); err != nil { t.Fatal(err) } - err = state.SaveState(dm.URL, dm.Destination, manualState) + err = store.SaveState(dm.URL, dm.Destination, manualState) if err != nil { t.Fatal(err) } @@ -139,7 +150,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { } // 7. Simulate Resume logic - paused, err := state.LoadPausedDownloads() + paused, err := store.LoadPausedDownloads() if err != nil { t.Fatal(err) } @@ -156,7 +167,7 @@ func TestResume_RespectsOriginalPath_WhenDefaultChanges(t *testing.T) { t.Errorf("Resumed path incorrect.\nGot: %s\nWant: %s", entry.DestPath, expectedPathA) } - // Verify that if we constructed a RuntimeConfig/DownloadConfig, it would use this absolute path + // Verify that if we constructed a RuntimeConfig/DownloadRecord, it would use this absolute path outputPath := filepath.Dir(entry.DestPath) // Even if logic checks for empty/dot, filepath.Dir of absolute path is absolute path. if outputPath == "" || outputPath == "." { diff --git a/internal/tui/startup_test.go b/internal/tui/startup_test.go index 2fbc8480a..21b8b8eee 100644 --- a/internal/tui/startup_test.go +++ b/internal/tui/startup_test.go @@ -7,11 +7,11 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/state" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/store" + "github.com/SurgeDM/Surge/internal/testutil" + "github.com/SurgeDM/Surge/internal/types" ) // TestTUI_Startup_HandlesResume verifies that TUI initialization handles resume logic correctly @@ -33,11 +33,11 @@ func TestTUI_Startup_HandlesResume(t *testing.T) { seedDownload(t, testID, testURL, testDest, "queued") // 3. Initialize TUI Model (Simulate StartTUI) - progressChan := make(chan any, 10) - pool := download.NewWorkerPool(progressChan, 3) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) // PASSING noResume=false (default) - m := InitialRootModel(1700, "test-version", core.NewLocalDownloadServiceWithInput(pool, progressChan), processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1700, "test-version", service.NewLocalDownloadService(mgr), mgr, false) // 4. Verify Download is Active in Model // InitialRootModel loads downloads and should set paused=false for "queued" items @@ -82,10 +82,10 @@ func TestTUI_Startup_LoadsCompletedTiming(t *testing.T) { const timeTakenMs = int64(2500) const avgSpeed = float64(2 * 1024 * 1024) // 2 MB/s - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: testID, URL: testURL, - URLHash: state.URLHash(testURL), + URLHash: "dummy-hash", DestPath: testDest, Filename: filepath.Base(testDest), Status: "completed", @@ -97,9 +97,9 @@ func TestTUI_Startup_LoadsCompletedTiming(t *testing.T) { t.Fatal(err) } - progressChan := make(chan any, 10) - pool := download.NewWorkerPool(progressChan, 3) - m := InitialRootModel(1700, "test-version", core.NewLocalDownloadServiceWithInput(pool, progressChan), processing.NewLifecycleManager(nil, nil), false) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) + m := InitialRootModel(1700, "test-version", service.NewLocalDownloadService(mgr), mgr, false) var found *DownloadModel for _, d := range m.downloads { @@ -135,10 +135,10 @@ func TestTUI_Startup_LoadsErroredDownloadsIntoDoneTab(t *testing.T) { testID := "tui-error-id" testURL := "http://example.com/error.bin" testDest := filepath.Join(tmpDir, "error.bin") - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: testID, URL: testURL, - URLHash: state.URLHash(testURL), + URLHash: "dummy-hash", DestPath: testDest, Filename: filepath.Base(testDest), Status: "error", @@ -146,9 +146,9 @@ func TestTUI_Startup_LoadsErroredDownloadsIntoDoneTab(t *testing.T) { t.Fatal(err) } - progressChan := make(chan any, 10) - pool := download.NewWorkerPool(progressChan, 3) - m := InitialRootModel(1700, "test-version", core.NewLocalDownloadServiceWithInput(pool, progressChan), processing.NewLifecycleManager(nil, nil), false) + bus := orchestrator.NewEventBus() + mgr := orchestrator.NewLifecycleManager(nil, bus) + m := InitialRootModel(1700, "test-version", service.NewLocalDownloadService(mgr), mgr, false) var found *DownloadModel for _, d := range m.downloads { @@ -184,15 +184,11 @@ func setupTestEnv(t *testing.T, tmpDir string) { } // Configure DB - dbPath := filepath.Join(surgeDir, "state", "surge.db") - _ = os.MkdirAll(filepath.Dir(dbPath), 0o755) - state.CloseDB() - t.Cleanup(state.CloseDB) - state.Configure(dbPath) + _ = testutil.SetupStateDB(t) } func seedDownload(t *testing.T, id, url, dest, status string) { - manualState := &types.DownloadState{ + manualState := &types.DownloadRecord{ ID: id, URL: url, Filename: filepath.Base(dest), @@ -202,7 +198,7 @@ func seedDownload(t *testing.T, id, url, dest, status string) { PausedAt: 0, CreatedAt: time.Now().Unix(), } - if err := state.AddToMasterList(types.DownloadEntry{ + if err := store.AddToMasterList(types.DownloadRecord{ ID: id, URL: url, DestPath: dest, @@ -213,7 +209,7 @@ func seedDownload(t *testing.T, id, url, dest, status string) { }); err != nil { t.Fatal(err) } - if err := state.SaveState(url, dest, manualState); err != nil { + if err := store.SaveState(url, dest, manualState); err != nil { t.Fatal(err) } } diff --git a/internal/tui/units_test_helper_test.go b/internal/tui/units_test_helper_test.go index 418ac6a58..510e6ac58 100644 --- a/internal/tui/units_test_helper_test.go +++ b/internal/tui/units_test_helper_test.go @@ -1,7 +1,7 @@ package tui import ( - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) diff --git a/internal/tui/update.go b/internal/tui/update.go index 9a372bd22..32a24ace2 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -13,7 +13,6 @@ import ( ) func (m RootModel) updatePaste(msg tea.PasteMsg) (tea.Model, tea.Cmd) { - if m.state == DashboardState && m.searchActive { var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) @@ -103,7 +102,6 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg := msg.(type) { - case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -208,7 +206,6 @@ func (m RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch m.state { - case DashboardState: return m.updateDashboard(msg) diff --git a/internal/tui/update_dashboard.go b/internal/tui/update_dashboard.go index e4aea82f5..fe2b43ef3 100644 --- a/internal/tui/update_dashboard.go +++ b/internal/tui/update_dashboard.go @@ -8,12 +8,11 @@ import ( tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/clipboard" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) func (m RootModel) updateDashboard(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - // Handle search input FIRST when active (intercepts ALL keys) if m.searchActive { switch msg.String() { diff --git a/internal/tui/update_events.go b/internal/tui/update_events.go index df81745ef..1f3e05e88 100644 --- a/internal/tui/update_events.go +++ b/internal/tui/update_events.go @@ -1,28 +1,42 @@ package tui import ( + engineprogress "github.com/SurgeDM/Surge/internal/progress" + "fmt" "strings" "time" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" - "github.com/SurgeDM/Surge/internal/engine/events" "github.com/SurgeDM/Surge/internal/tui/components" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) +func stateProgress(state interface{}) *engineprogress.DownloadProgress { + if state == nil { + return nil + } + if dr, ok := state.(*types.DownloadRecord); ok { + return engineprogress.CfgProgress(dr) + } + return nil +} + func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { + if ev, ok := msg.(types.DownloadEvent); ok { + return m.handleDownloadEvent(ev) + } switch msg := msg.(type) { - case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) needsSpinner := false for _, d := range m.downloads { - if d.pausing || d.resuming || components.DetermineStatus(d.done, d.paused, d.err != nil, d.Speed, d.Downloaded) == components.StatusQueued { + if d.pausing || d.resuming || components.DetermineStatus(d.done, d.paused, d.err != nil, d.started, d.resuming) == components.StatusQueued { needsSpinner = true break } @@ -102,14 +116,27 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { m.addLogEntry(LogStyleError.Render("\u2716 Failed to enqueue download: " + msg.err.Error())) return m, nil - case events.DownloadRequestMsg: + case startupConfigWarningMsg: + for _, w := range msg { + if w != "" { + m.addLogEntry(LogStyleError.Render("\u26a0 " + w)) + } + } + return m, nil + } + + return m, nil +} + +func (m RootModel) handleDownloadEvent(msg types.DownloadEvent) (tea.Model, tea.Cmd) { + switch msg.Type { + case types.EventRequest: return m.handleDownloadRequestMsg(msg, true) - case events.BatchDownloadRequestMsg: + case types.EventBatchRequest: return m.handleBatchDownloadRequestMsg(msg, true) - case events.DownloadStartedMsg: - + case types.EventStarted: found := false if d := m.FindDownloadByID(msg.DownloadID); d != nil { d.Filename = msg.Filename @@ -121,17 +148,15 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { d.StartTime = time.Now() d.paused = false d.pausing = false - // Keep resuming=true for resumed downloads until real transfer starts. - // Update progress bar var progressCmd tea.Cmd if d.Total > 0 { progressCmd = d.progress.SetPercent(0) } if d.state == nil && msg.State != nil { - d.state = msg.State + d.state = stateProgress(msg.State) } if d.state != nil { - d.state.SetTotalSize(msg.Total) // Keep state updated for verification if needed + d.state.SetTotalSize(msg.Total) } d.started = true m.SelectedDownloadID = msg.DownloadID @@ -146,7 +171,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { newDownload.RateLimit = msg.RateLimit newDownload.RateLimitSet = msg.RateLimitSet if msg.State != nil { - newDownload.state = msg.State + newDownload.state = stateProgress(msg.State) } newDownload.started = true m.downloads = append(m.downloads, newDownload) @@ -155,22 +180,20 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { m.addLogEntry(LogStyleStarted.Render("\u2b07 Started: " + msg.Filename)) return m, m.spinner.Tick } - case events.ProgressMsg: + + case types.EventProgress: cmd := m.processProgressMsg(msg) return m, cmd - case events.BatchProgressMsg: + case types.EventBatchProgress: var cmds []tea.Cmd - for _, bm := range msg { + for _, bm := range msg.BatchEvents { cmds = append(cmds, m.processProgressMsg(bm)) } - // Only update UI once per batch return m, tea.Batch(cmds...) - case events.DownloadCompleteMsg: - + case types.EventComplete: var cmds []tea.Cmd - if d := m.FindDownloadByID(msg.DownloadID); d != nil { if !d.done { d.Total = msg.Total @@ -192,7 +215,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { m.UpdateListItems() return m, tea.Batch(cmds...) - case events.DownloadErrorMsg: + case types.EventError: found := false if d := m.FindDownloadByID(msg.DownloadID); d != nil { d.err = msg.Err @@ -210,7 +233,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { m.UpdateListItems() return m, nil - case events.DownloadPausedMsg: + case types.EventPaused: if d := m.FindDownloadByID(msg.DownloadID); d != nil { d.paused = true d.pausing = false @@ -224,7 +247,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { m.UpdateListItems() return m, nil - case events.DownloadResumedMsg: + case types.EventResumed: if d := m.FindDownloadByID(msg.DownloadID); d != nil { d.paused = false d.pausing = false @@ -234,8 +257,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { m.UpdateListItems() return m, m.spinner.Tick - case events.DownloadQueuedMsg: - // We optimistically added it, but if it came from elsewhere, handle it + case types.EventQueued: found := false if d := m.FindDownloadByID(msg.DownloadID); d != nil { d.RateLimit = msg.RateLimit @@ -243,7 +265,6 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { found = true } if !found { - // Add placeholder newDownload := NewDownloadModel(msg.DownloadID, msg.URL, msg.Filename, 0) newDownload.Destination = msg.DestPath newDownload.RateLimit = msg.RateLimit @@ -255,7 +276,7 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case events.DownloadRemovedMsg: + case types.EventRemoved: if m.removeDownloadByID(msg.DownloadID) { if msg.Filename != "" { m.addLogEntry(LogStyleError.Render("\u2716 Removed: " + msg.Filename)) @@ -264,19 +285,11 @@ func (m RootModel) updateEvents(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case events.SystemLogMsg: + case types.EventSystem: if msg.Message != "" { m.addLogEntry(LogStyleStarted.Render("\u2139 " + msg.Message)) } return m, nil - - case startupConfigWarningMsg: - for _, w := range msg { - if w != "" { - m.addLogEntry(LogStyleError.Render("\u26a0 " + w)) - } - } - return m, nil } return m, nil diff --git a/internal/tui/update_modals.go b/internal/tui/update_modals.go index 7ede308cb..d8ffb0175 100644 --- a/internal/tui/update_modals.go +++ b/internal/tui/update_modals.go @@ -11,7 +11,7 @@ import ( "github.com/SurgeDM/Surge/internal/bugreport" "github.com/SurgeDM/Surge/internal/clipboard" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/engine/types" + "github.com/SurgeDM/Surge/internal/types" "github.com/SurgeDM/Surge/internal/utils" ) @@ -189,7 +189,6 @@ func (m RootModel) updateDuplicateWarning(msg tea.KeyPressMsg) (tea.Model, tea.C } func (m RootModel) updateQuitConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - confirmQuit := func() (tea.Model, tea.Cmd) { if m.cancelEnqueue != nil { m.cancelEnqueue() @@ -225,7 +224,6 @@ func (m RootModel) updateQuitConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } func (m RootModel) updateBatchConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if key.Matches(msg, m.keys.BatchConfirm.Confirm) { // Add all URLs as downloads, skipping duplicates path := strings.TrimSpace(m.inputs[2].Value()) @@ -251,7 +249,7 @@ func (m RootModel) updateBatchConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) continue } var cmd tea.Cmd - m, cmd = m.startDownload(request.URL, request.Mirrors, request.Headers, requestPath, isDefaultPath, request.Filename, request.ID, request.Workers, request.MinChunkSize) + m, cmd = m.startDownload(request.URL, request.Mirrors, request.Headers, requestPath, isDefaultPath, request.Filename, request.DownloadID, request.Workers, request.MinChunkSize) if cmd != nil { batchCmds = append(batchCmds, cmd) } @@ -296,7 +294,6 @@ func (m RootModel) updateBatchConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) } func (m RootModel) updateURLUpdate(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if key.Matches(msg, m.keys.Input.Esc) { m.state = DashboardState m.urlUpdateInput.SetValue("") diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 5d3e88053..95143c29c 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -1,6 +1,9 @@ package tui import ( + "github.com/SurgeDM/Surge/internal/orchestrator" + engineprogress "github.com/SurgeDM/Surge/internal/progress" + "context" "errors" "os" @@ -13,13 +16,31 @@ import ( "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/core" - "github.com/SurgeDM/Surge/internal/download" - "github.com/SurgeDM/Surge/internal/engine/events" - "github.com/SurgeDM/Surge/internal/engine/types" - "github.com/SurgeDM/Surge/internal/processing" + + "github.com/SurgeDM/Surge/internal/scheduler" + "github.com/SurgeDM/Surge/internal/service" + "github.com/SurgeDM/Surge/internal/types" ) +type updateMockService struct { + service.DownloadService + addFunc func(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64) (string, error) +} + +func (m *updateMockService) Add(url string, path string, filename string, mirrors []string, headers map[string]string, isExplicitCategory bool, workers int, minChunkSize int64) (string, error) { + if m.addFunc != nil { + return m.addFunc(url, path, filename, mirrors, headers, isExplicitCategory, workers, minChunkSize) + } + return "mock-id", nil +} + +func (m *updateMockService) AddWithID(url string, path string, filename string, mirrors []string, headers map[string]string, id string, workers int, minChunkSize int64) (string, error) { + if m.addFunc != nil { + return m.addFunc(url, path, filename, mirrors, headers, false, workers, minChunkSize) + } + return id, nil +} + var errTest = errors.New("test error") func newInputModels() []textinput.Model { @@ -88,13 +109,14 @@ func TestUpdate_DownloadStartedKeepsResuming(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - msg := events.DownloadStartedMsg{ + msg := types.DownloadEvent{ + Type: types.EventStarted, DownloadID: "id-1", URL: "http://example.com/file", Filename: "file", Total: 100, DestPath: "/tmp/file", - State: types.NewProgressState("id-1", 100), + State: &types.DownloadRecord{ProgressState: engineprogress.New("id-1", 100)}, } updated, _ := m.Update(msg) @@ -123,13 +145,14 @@ func TestUpdate_DownloadStartedPropagatesRateLimit(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - updated, _ := m.Update(events.DownloadStartedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventStarted, DownloadID: "id-1", URL: "http://example.com/file", Filename: "file", Total: 100, DestPath: "/tmp/file", - State: types.NewProgressState("id-1", 100), + State: &types.DownloadRecord{ProgressState: engineprogress.New("id-1", 100)}, RateLimit: 2 * 1024 * 1024, RateLimitSet: true, }) @@ -146,13 +169,14 @@ func TestUpdate_DownloadStartedNewDownloadPropagatesRateLimit(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - updated, _ := m.Update(events.DownloadStartedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventStarted, DownloadID: "id-1", URL: "http://example.com/file", Filename: "file", Total: 100, DestPath: "/tmp/file", - State: types.NewProgressState("id-1", 100), + State: &types.DownloadRecord{ProgressState: engineprogress.New("id-1", 100)}, RateLimit: 3 * 1024 * 1024, RateLimitSet: true, }) @@ -178,13 +202,14 @@ func TestUpdate_EnqueueSuccessMergesOptimisticEntryAfterStart(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - updated, _ := m.Update(events.DownloadStartedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventStarted, DownloadID: "real-1", URL: "http://example.com/file", Filename: "file.bin", Total: 100, DestPath: "/tmp/file.bin", - State: types.NewProgressState("real-1", 100), + State: &types.DownloadRecord{ProgressState: engineprogress.New("real-1", 100)}, }) m2 := updated.(RootModel) if len(m2.downloads) != 2 { @@ -221,7 +246,8 @@ func TestUpdate_PauseResumeEventsNormalizeFlags(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - updated, _ := m.Update(events.DownloadPausedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventPaused, DownloadID: "id-1", Filename: "file", Downloaded: 50, @@ -232,7 +258,8 @@ func TestUpdate_PauseResumeEventsNormalizeFlags(t *testing.T) { t.Fatalf("Expected paused=true and others false after DownloadPausedMsg, got paused=%v pausing=%v resuming=%v", d.paused, d.pausing, d.resuming) } - updated, _ = m2.Update(events.DownloadResumedMsg{ + updated, _ = m2.Update(types.DownloadEvent{ + Type: types.EventResumed, DownloadID: "id-1", Filename: "file", }) @@ -252,7 +279,8 @@ func TestUpdate_DownloadPausedPropagatesRateLimit(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - updated, _ := m.Update(events.DownloadPausedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventPaused, DownloadID: "id-1", Filename: "file", Downloaded: 50, @@ -272,7 +300,8 @@ func TestUpdate_DownloadQueuedPropagatesRateLimit(t *testing.T) { logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - updated, _ := m.Update(events.DownloadQueuedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventQueued, DownloadID: "id-1", Filename: "file", URL: "http://example.com/file", @@ -298,7 +327,8 @@ func TestUpdate_DownloadQueuedExistingDownloadPropagatesRateLimit(t *testing.T) logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } - updated, _ := m.Update(events.DownloadQueuedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventQueued, DownloadID: "id-1", Filename: "file", URL: "http://example.com/file", @@ -323,7 +353,8 @@ func TestProcessProgressMsg_ClearsResumingOnTransfer(t *testing.T) { } // No transfer yet: keep resuming. - m.processProgressMsg(events.ProgressMsg{ + m.processProgressMsg(types.DownloadEvent{ + Type: types.EventProgress, DownloadID: "id-1", Downloaded: 50, Total: 100, @@ -334,7 +365,8 @@ func TestProcessProgressMsg_ClearsResumingOnTransfer(t *testing.T) { } // Transfer observed: clear resuming. - m.processProgressMsg(events.ProgressMsg{ + m.processProgressMsg(types.DownloadEvent{ + Type: types.EventProgress, DownloadID: "id-1", Downloaded: 60, Total: 100, @@ -356,7 +388,8 @@ func TestUpdate_DownloadComplete_UsesAverageSpeed(t *testing.T) { elapsed := 4 * time.Second avgSpeed := float64(26400000) / elapsed.Seconds() - updated, _ := m.Update(events.DownloadCompleteMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventComplete, DownloadID: "id-1", Filename: "file.bin", Elapsed: elapsed, @@ -424,7 +457,8 @@ func TestUpdate_DownloadRemovedRemovesFromModelAndList(t *testing.T) { } m.UpdateListItems() - updated, _ := m.Update(events.DownloadRemovedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventRemoved, DownloadID: "id-1", Filename: "file", }) @@ -448,7 +482,8 @@ func TestUpdate_DownloadRemoved_NoOpWhenUnknownID(t *testing.T) { } m.UpdateListItems() - updated, _ := m.Update(events.DownloadRemovedMsg{ + updated, _ := m.Update(types.DownloadEvent{ + Type: types.EventRemoved, DownloadID: "id-unknown", Filename: "file", }) @@ -467,7 +502,8 @@ func TestProcessProgressMsg_UpdatesElapsed(t *testing.T) { } elapsed := 12 * time.Second - m.processProgressMsg(events.ProgressMsg{ + m.processProgressMsg(types.DownloadEvent{ + Type: types.EventProgress, DownloadID: "id-1", Downloaded: 400, Total: 1000, @@ -489,9 +525,8 @@ func TestGenerateUniqueFilename_IncompleteSuffixConstant(t *testing.T) { func TestUpdate_DownloadRequestMsg(t *testing.T) { // Setup initial model - ch := make(chan any, 100) - pool := download.NewWorkerPool(ch, 1) - svc := core.NewLocalDownloadServiceWithInput(pool, ch) + bus := orchestrator.NewEventBus() + svc := service.NewLocalDownloadService(orchestrator.NewLifecycleManager(nil, bus)) t.Cleanup(func() { _ = svc.Shutdown() }) m := RootModel{ @@ -506,7 +541,8 @@ func TestUpdate_DownloadRequestMsg(t *testing.T) { m.Settings.Extension.ExtensionPrompt.Value = true m.Settings.General.WarnOnDuplicate.Value = true - msg := events.DownloadRequestMsg{ + msg := types.DownloadEvent{ + Type: types.EventRequest, URL: "http://example.com/test.zip", Filename: "test.zip", Path: "/tmp/downloads", @@ -573,12 +609,14 @@ func TestUpdate_DownloadRequestMsg_QueuesWhileConfirmationActive(t *testing.T) { } m.Settings.Extension.ExtensionPrompt.Value = true - first := events.DownloadRequestMsg{ + first := types.DownloadEvent{ + Type: types.EventRequest, URL: "https://example.com/first.zip", Filename: "first.zip", Path: "/tmp/downloads", } - second := events.DownloadRequestMsg{ + second := types.DownloadEvent{ + Type: types.EventRequest, URL: "https://example.com/second.zip", Filename: "second.zip", Path: "/tmp/downloads", @@ -619,15 +657,17 @@ func TestUpdate_BatchDownloadRequestMsg_QueuesWhileConfirmationActive(t *testing } m.Settings.Extension.ExtensionPrompt.Value = true - first := events.DownloadRequestMsg{ + first := types.DownloadEvent{ + Type: types.EventRequest, URL: "https://example.com/first.zip", Filename: "first.zip", Path: "/tmp/downloads", } - batch := events.BatchDownloadRequestMsg{ + batch := types.DownloadEvent{ + Type: types.EventBatchRequest, Path: "/tmp/batch", - Requests: []events.DownloadRequestMsg{ - {URL: "https://example.com/one.zip", Path: "/tmp/batch"}, + BatchEvents: []types.DownloadEvent{ + {Type: types.EventRequest, URL: "https://example.com/one.zip", Path: "/tmp/batch"}, {URL: "https://example.com/two.zip", Path: "/tmp/batch"}, }, } @@ -661,12 +701,9 @@ func TestUpdate_BatchDownloadRequestMsg_QueuesWhileConfirmationActive(t *testing } func TestStartDownload_UsesProvidedIDWhenServiceSupportsIt(t *testing.T) { - ch := make(chan any, 16) - pool := download.NewWorkerPool(ch, 1) - svc := core.NewLocalDownloadServiceWithInput(pool, ch) - t.Cleanup(func() { - _ = svc.Shutdown() - }) + svc := &updateMockService{ + DownloadService: &mockService{}, + } m := RootModel{ Settings: config.DefaultSettings(), @@ -688,26 +725,21 @@ func TestStartDownload_UsesProvidedIDWhenServiceSupportsIt(t *testing.T) { } func TestStartDownload_UsesModelEnqueueContext(t *testing.T) { - svc := core.NewLocalDownloadServiceWithInput(nil, nil) - t.Cleanup(func() { - _ = svc.Shutdown() - }) - ctx, cancel := context.WithCancel(context.Background()) cancel() - orchestrator := processing.NewLifecycleManager( - func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { + svc := &updateMockService{ + DownloadService: &mockService{}, + addFunc: func(string, string, string, []string, map[string]string, bool, int, int64) (string, error) { t.Fatal("enqueue dispatch should not run after context cancellation") return "", nil }, - nil, - ) + } m := RootModel{ Settings: config.DefaultSettings(), Service: svc, - Orchestrator: orchestrator, + Orchestrator: orchestrator.NewLifecycleManager(scheduler.New(make(chan types.DownloadEvent, 16), 1), orchestrator.NewEventBus()), enqueueCtx: ctx, cancelEnqueue: func() {}, list: NewDownloadList(80, 20), @@ -733,23 +765,18 @@ func TestStartDownload_UsesModelEnqueueContext(t *testing.T) { } func TestStartDownload_GuessesFilenameOptimisticallyWhenProvidedOrInferred(t *testing.T) { - svc := core.NewLocalDownloadServiceWithInput(nil, nil) - t.Cleanup(func() { - _ = svc.Shutdown() - }) - - orchestrator := processing.NewLifecycleManager( - func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { + svc := &updateMockService{ + DownloadService: &mockService{}, + addFunc: func(string, string, string, []string, map[string]string, bool, int, int64) (string, error) { return "real-id", nil }, - nil, - ) + } targetDir := t.TempDir() m := RootModel{ Settings: config.DefaultSettings(), Service: svc, - Orchestrator: orchestrator, + Orchestrator: orchestrator.NewLifecycleManager(nil, orchestrator.NewEventBus()), list: NewDownloadList(80, 20), logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } @@ -769,23 +796,18 @@ func TestStartDownload_GuessesFilenameOptimisticallyWhenProvidedOrInferred(t *te } func TestStartDownload_UsesGenericQueuedNameForExplicitFilenameUntilLifecycleConfirms(t *testing.T) { - svc := core.NewLocalDownloadServiceWithInput(nil, nil) - t.Cleanup(func() { - _ = svc.Shutdown() - }) - - orchestrator := processing.NewLifecycleManager( - func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { + svc := &updateMockService{ + DownloadService: &mockService{}, + addFunc: func(string, string, string, []string, map[string]string, bool, int, int64) (string, error) { return "real-id", nil }, - nil, - ) + } targetDir := t.TempDir() m := RootModel{ Settings: config.DefaultSettings(), Service: svc, - Orchestrator: orchestrator, + Orchestrator: orchestrator.NewLifecycleManager(nil, orchestrator.NewEventBus()), list: NewDownloadList(80, 20), logViewport: viewport.New(viewport.WithWidth(40), viewport.WithHeight(5)), } @@ -1048,23 +1070,18 @@ func TestQuitConfirm_UnrelatedKeyIgnored(t *testing.T) { } func TestWithEnqueueContext_OverridesStartDownloadContext(t *testing.T) { - svc := core.NewLocalDownloadServiceWithInput(nil, nil) - t.Cleanup(func() { - _ = svc.Shutdown() - }) - ctx, cancel := context.WithCancel(context.Background()) cancel() - orchestrator := processing.NewLifecycleManager( - func(string, string, string, []string, map[string]string, bool, int, int64, int64, bool) (string, error) { + svc := &updateMockService{ + DownloadService: &mockService{}, + addFunc: func(string, string, string, []string, map[string]string, bool, int, int64) (string, error) { t.Fatal("enqueue dispatch should not run after shared context cancellation") return "", nil }, - nil, - ) + } - m := InitialRootModel(1700, "test-version", svc, orchestrator, false) + m := InitialRootModel(1700, "test-version", svc, orchestrator.NewLifecycleManager(scheduler.New(make(chan types.DownloadEvent, 16), 1), orchestrator.NewEventBus()), false) m = m.WithEnqueueContext(ctx, func() {}) _, cmd := m.startDownload("https://example.com/file.bin", nil, nil, t.TempDir(), false, "file.bin", "", 0, 0) @@ -1092,7 +1109,7 @@ func TestUpdate_RefreshShortcut(t *testing.T) { state: DashboardState, keys: config.DefaultKeyMap(), urlUpdateInput: textinput.New(), - Service: core.NewLocalDownloadServiceWithInput(nil, nil), + Service: service.NewLocalDownloadService(orchestrator.NewLifecycleManager(nil, orchestrator.NewEventBus())), } m.UpdateListItems() m.list.Select(0) // Select the paused download diff --git a/internal/tui/view.go b/internal/tui/view.go index d4c1d6be6..18a4da5b1 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -515,11 +515,9 @@ func (m RootModel) View() tea.View { detailBox := renderBtopBox("", PaneTitleStyle.Render(" File Details "), detailContent, layout.LeftWidth, layout.DetailHeight, colors.Gray()) body = lipgloss.JoinVertical(lipgloss.Left, headerBox, listBox, detailBox) } else { - body = lipgloss.JoinVertical(lipgloss.Left, headerBox, listBox) } } else { - leftColumn := lipgloss.JoinVertical(lipgloss.Left, headerBox, listBox) body = lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, rightColumn) } @@ -805,7 +803,7 @@ func getDownloadStatus(d *DownloadModel, spinnerView string) string { if d.resuming { return lipgloss.NewStyle().Foreground(colors.StateDownloading()).Render(spinnerView + " Resuming...") } - status := components.DetermineStatus(d.done, d.paused, d.err != nil, d.Speed, d.Downloaded) + status := components.DetermineStatus(d.done, d.paused, d.err != nil, d.started, d.resuming) return status.RenderWithSpinner(spinnerView) } diff --git a/internal/tui/view_dashboard_chunkmap.go b/internal/tui/view_dashboard_chunkmap.go index 19557c17f..154044f92 100644 --- a/internal/tui/view_dashboard_chunkmap.go +++ b/internal/tui/view_dashboard_chunkmap.go @@ -22,7 +22,6 @@ func (m *RootModel) renderChunkMapBox(width, height int, selected *DownloadModel if len(bitmap) == 0 || bitmapWidth == 0 { innerContent = renderEmptyMessage(contentWidth, contentHeight, "Chunk visualization not available") } else { - targetRows := contentHeight if targetRows < 3 { targetRows = 3 @@ -46,7 +45,6 @@ func (m *RootModel) renderChunkMapBox(width, height int, selected *DownloadModel chunkContentWrapper := chunkMapPadding.Render(chunkMap.View()) innerContent = lipgloss.Place(contentWidth, contentHeight, lipgloss.Center, lipgloss.Top, chunkContentWrapper) - } return renderBtopBox("", PaneTitleStyle.Render(" Chunk Map "), innerContent, width, height, colors.Gray()) diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index ee544f654..0b1ef835e 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/SurgeDM/Surge/internal/config" - "github.com/SurgeDM/Surge/internal/processing" + "github.com/SurgeDM/Surge/internal/orchestrator" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -130,7 +130,7 @@ func TestGetDownloadStatus(t *testing.T) { } func TestView_DashboardFitsViewportWithoutTopCutoff(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) cases := []struct { width int @@ -168,7 +168,7 @@ func TestView_DashboardFitsViewportWithoutTopCutoff(t *testing.T) { } func TestView_QuitConfirmContainsButtons(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = QuitConfirmState m.width = 120 m.height = 35 @@ -186,7 +186,7 @@ func TestView_QuitConfirmContainsButtons(t *testing.T) { } func TestView_QuitConfirmShowsActiveDownloadDetail(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = QuitConfirmState m.width = 120 m.height = 35 @@ -201,7 +201,7 @@ func TestView_QuitConfirmShowsActiveDownloadDetail(t *testing.T) { } func TestView_QuitConfirmNoFocusedRendersCorrectly(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = QuitConfirmState m.quitConfirmFocused = 1 m.width = 120 @@ -214,7 +214,7 @@ func TestView_QuitConfirmNoFocusedRendersCorrectly(t *testing.T) { } func TestView_QuitConfirmTinyTerminalDoesNotPanic(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = QuitConfirmState m.width = 10 m.height = 5 @@ -222,7 +222,7 @@ func TestView_QuitConfirmTinyTerminalDoesNotPanic(t *testing.T) { } func TestView_SettingsTinyTerminalDoesNotPanic(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = SettingsState m.width = 20 m.height = 8 @@ -234,7 +234,7 @@ func TestView_SettingsTinyTerminalDoesNotPanic(t *testing.T) { } func TestView_SettingsNoLineExceedsTerminalWidth(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = SettingsState sizes := []struct{ width, height int }{ @@ -266,7 +266,7 @@ func TestView_SettingsResizeSequenceKeepsSelectedVisible(t *testing.T) { selectedRow := len(metadata) - 1 selectedLabel := metadata[selectedRow].Label - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = SettingsState m.SettingsActiveTab = 0 m.SettingsSelectedRow = selectedRow @@ -294,7 +294,7 @@ func TestView_SettingsResizeSequenceKeepsSelectedVisible(t *testing.T) { } func TestView_SettingsEditModeNarrowWidthNoOverflow(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = SettingsState m.width = 55 m.height = 16 @@ -312,7 +312,7 @@ func TestView_SettingsEditModeNarrowWidthNoOverflow(t *testing.T) { } func TestView_CategoryManagerNoLineExceedsTerminalWidth(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = CategoryManagerState sizes := []struct{ width, height int }{ @@ -344,7 +344,7 @@ func TestView_CategoryManagerResizeSequenceKeepsSelectedVisible(t *testing.T) { selectedCursor := len(settings.Categories.Categories) - 1 selectedLabel := settings.Categories.Categories[selectedCursor].Name - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = CategoryManagerState m.Settings = settings m.catMgrCursor = selectedCursor @@ -372,7 +372,7 @@ func TestView_CategoryManagerResizeSequenceKeepsSelectedVisible(t *testing.T) { } func TestView_CategoryManagerEditModeNarrowWidthNoOverflow(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.state = CategoryManagerState m.width = 55 m.height = 16 @@ -393,7 +393,7 @@ func TestView_CategoryManagerEditModeNarrowWidthNoOverflow(t *testing.T) { } func TestView_NetworkActivityShowsFiveAxisLabelsWhenTall(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 140 m.height = 40 @@ -430,7 +430,7 @@ func BenchmarkCachedLogo(b *testing.B) { /____/\__,_/_/ \__, /\___/ /____/ ` - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) // Pre-warm cache gradientLogo := ApplyGradient(logoText, colors.Pink(), colors.Magenta()) m.logoCache = lipgloss.NewStyle().Render(gradientLogo) @@ -448,7 +448,7 @@ func BenchmarkCachedLogo(b *testing.B) { // Tests for issue #252: TUI layout breakage on non-standard terminal sizes func TestView_NoLineExceedsTerminalWidth(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) sizes := []struct{ width, height int }{ {160, 40}, // full layout @@ -477,7 +477,7 @@ func TestView_NoBoxCorruptionAtNarrowWidths(t *testing.T) { "╭╭", "╮╮", "╰╰", "╯╯", // doubled corners } - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) sizes := []struct{ width, height int }{ {160, 40}, @@ -501,7 +501,7 @@ func TestView_NoBoxCorruptionAtNarrowWidths(t *testing.T) { } func TestView_RightColumnHiddenAtNarrowWidth(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 60 m.height = 20 @@ -518,7 +518,7 @@ func TestView_RightColumnHiddenAtNarrowWidth(t *testing.T) { } func TestView_LogoHiddenAtNarrowWidth(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 60 m.height = 24 @@ -533,7 +533,7 @@ func TestView_LogoHiddenAtNarrowWidth(t *testing.T) { } func TestView_FooterHidesHelpTextAtNarrowWidth(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 40 m.height = 24 @@ -551,7 +551,7 @@ func TestView_FooterHidesHelpTextAtNarrowWidth(t *testing.T) { } func TestView_TerminalTooSmallMessage(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) sizes := []struct{ width, height int }{ {40, 10}, @@ -571,7 +571,7 @@ func TestView_TerminalTooSmallMessage(t *testing.T) { } func TestHelpModal_RendersAndClosesOnEsc(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 120 m.height = 40 m.state = HelpModalState @@ -592,7 +592,7 @@ func TestHelpModal_RendersAndClosesOnEsc(t *testing.T) { } func TestView_DoesNotPanicAtExtremeSizes(t *testing.T) { - m := InitialRootModel(1701, "test-version", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "test-version", nil, orchestrator.NewLifecycleManager(nil, nil), false) extremeSizes := []struct{ width, height int }{ {40, 12}, // very narrow and short @@ -625,7 +625,7 @@ func footerLine(m RootModel) string { func TestFooter_GlyphsAlwaysPresent(t *testing.T) { InitializeTUI() - m := InitialRootModel(1701, "1.2.3", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "1.2.3", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 120 m.height = 35 @@ -639,7 +639,7 @@ func TestFooter_GlyphsAlwaysPresent(t *testing.T) { func TestFooter_VersionShown(t *testing.T) { InitializeTUI() - m := InitialRootModel(1701, "9.8.7", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "9.8.7", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 120 m.height = 35 @@ -652,7 +652,7 @@ func TestFooter_VersionShown(t *testing.T) { func TestFooter_IdleSpeedShowsZero(t *testing.T) { InitializeTUI() // No active downloads \u2192 speed should render as "0 B/s" - m := InitialRootModel(1701, "1.0.0", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "1.0.0", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 120 m.height = 35 @@ -664,7 +664,7 @@ func TestFooter_IdleSpeedShowsZero(t *testing.T) { func TestFooter_ActiveSpeedShowsMBps(t *testing.T) { InitializeTUI() - m := InitialRootModel(1701, "1.0.0", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "1.0.0", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 120 m.height = 35 // Inject an active download at 2.5 MB/s (in bytes/s) @@ -683,7 +683,7 @@ func TestFooter_ActiveSpeedShowsMBps(t *testing.T) { func TestFooter_ActiveSpeedShowsKBps(t *testing.T) { InitializeTUI() - m := InitialRootModel(1701, "1.0.0", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "1.0.0", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 120 m.height = 35 // 512 KB/s = 0.5 MB/s \u2192 should render as KB/s @@ -699,7 +699,7 @@ func TestFooter_ActiveSpeedShowsKBps(t *testing.T) { func TestFooter_ZeroRateLimitShowsInfinity(t *testing.T) { InitializeTUI() - m := InitialRootModel(1701, "1.0.0", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "1.0.0", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 120 m.height = 35 // Default settings have no global rate limit \u2192 \u221E @@ -713,7 +713,7 @@ func TestFooter_ZeroRateLimitShowsInfinity(t *testing.T) { func TestFooter_GlobalRateLimitShown(t *testing.T) { InitializeTUI() - m := InitialRootModel(1701, "1.0.0", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "1.0.0", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 120 m.height = 35 @@ -733,7 +733,7 @@ func TestFooter_HidesHelpAtNarrowWidth(t *testing.T) { InitializeTUI() // width=55: above MinTermWidth(45) so the real dashboard renders, but below // the 60-char threshold where we drop help text from the footer. - m := InitialRootModel(1701, "1.0.0", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "1.0.0", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = 55 m.height = 24 @@ -760,7 +760,7 @@ func TestFooter_NoLineOverflowAtVariousSizes(t *testing.T) { {60, 20}, } for _, tc := range sizes { - m := InitialRootModel(1701, "1.0.0", nil, processing.NewLifecycleManager(nil, nil), false) + m := InitialRootModel(1701, "1.0.0", nil, orchestrator.NewLifecycleManager(nil, nil), false) m.width = tc.width m.height = tc.height for i, line := range strings.Split(m.View().Content, "\n") { diff --git a/internal/engine/types/config.go b/internal/types/config.go similarity index 88% rename from internal/engine/types/config.go rename to internal/types/config.go index 43b5462d2..4dbe1a65c 100644 --- a/internal/engine/types/config.go +++ b/internal/types/config.go @@ -44,36 +44,14 @@ const ( ProgressChannelBuffer = 100 - RateLimitBaseBackoff = 1 * time.Second - RateLimitMaxBackoff = 30 * time.Second - RateLimitMinBackoff = 500 * time.Millisecond + RateLimitBaseBackoff = 1 * time.Second + RateLimitMaxBackoff = 30 * time.Second + RateLimitMinBackoff = 500 * time.Millisecond RateLimitJitterFraction = 0.2 - RateLimitPenaltyDecay = 60 * time.Second - RateLimitMaxRetries = 6 + RateLimitPenaltyDecay = 60 * time.Second + RateLimitMaxRetries = 6 ) -type DownloadConfig struct { - URL string - OutputPath string - DestPath string - ID string - Filename string - IsResume bool - ProgressCh chan<- any - State *ProgressState - SavedState *DownloadState - Runtime *RuntimeConfig - Mirrors []string - Headers map[string]string - Limiter ByteLimiter - - IsExplicitCategory bool - TotalSize int64 - SupportsRange bool - RateLimitBps int64 - RateLimitSet bool -} - // ByteLimiter abstracts byte-based throttling for downloads. type ByteLimiter interface { WaitN(ctx context.Context, n int64) error diff --git a/internal/engine/types/config_test.go b/internal/types/config_test.go similarity index 96% rename from internal/engine/types/config_test.go rename to internal/types/config_test.go index a4a5c3c63..d46d0d6ff 100644 --- a/internal/engine/types/config_test.go +++ b/internal/types/config_test.go @@ -252,18 +252,18 @@ func TestChannelBufferSizes(t *testing.T) { } } -func TestDownloadConfig_Fields(t *testing.T) { - state := NewProgressState("test", 1000) +func TestDownloadRecord_Fields(t *testing.T) { + state := &struct{}{} runtime := &RuntimeConfig{MaxConnectionsPerDownload: 8} - cfg := DownloadConfig{ - URL: "https://example.com/file.zip", - OutputPath: "/tmp/file.zip", - ID: "download-123", - Filename: "file.zip", - ProgressCh: nil, - State: state, - Runtime: runtime, + cfg := DownloadRecord{ + URL: "https://example.com/file.zip", + OutputPath: "/tmp/file.zip", + ID: "download-123", + Filename: "file.zip", + ProgressCh: nil, + ProgressState: state, + Runtime: runtime, } if cfg.URL != "https://example.com/file.zip" { @@ -275,7 +275,7 @@ func TestDownloadConfig_Fields(t *testing.T) { if cfg.ID != "download-123" { t.Error("ID not set correctly") } - if cfg.State != state { + if cfg.ProgressState != state { t.Error("State not set correctly") } if cfg.Runtime != runtime { diff --git a/internal/engine/types/errors.go b/internal/types/errors.go similarity index 92% rename from internal/engine/types/errors.go rename to internal/types/errors.go index fa8092148..c3db53a39 100644 --- a/internal/engine/types/errors.go +++ b/internal/types/errors.go @@ -17,4 +17,5 @@ var ( ErrQueuedUpdate = errors.New("cannot update URL for a queued download, please cancel or wait for it to start") ErrActiveUpdate = errors.New("download is currently active, please pause it before updating the URL") ErrMaxRedirects = errors.New("stopped after 10 redirects") + ErrAlreadyActive = errors.New("download is already active or queued") ) diff --git a/internal/types/events.go b/internal/types/events.go new file mode 100644 index 000000000..6f524773f --- /dev/null +++ b/internal/types/events.go @@ -0,0 +1,211 @@ +package types + +import ( + "encoding/json" + "errors" + "time" +) + +type EventType int + +const ( + EventStarted EventType = iota + EventProgress + EventComplete + EventPaused + EventResumed + EventQueued + EventRemoved + EventError + EventRequest + EventBatchRequest + EventBatchProgress + EventSystem +) + +type DownloadEvent struct { + Type EventType `json:"type"` + DownloadID string `json:"download_id,omitempty"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + DestPath string `json:"dest_path,omitempty"` + + // Progress + Downloaded int64 `json:"downloaded,omitempty"` + Total int64 `json:"total,omitempty"` + Speed float64 `json:"speed,omitempty"` + Connections int `json:"connections,omitempty"` + RateLimited bool `json:"rate_limited,omitempty"` + ChunkBitmap []byte `json:"chunk_bitmap,omitempty"` + BitmapWidth int `json:"bitmap_width,omitempty"` + ChunkSize int64 `json:"chunk_size,omitempty"` + ChunkProgress []int64 `json:"chunk_progress,omitempty"` + + // Completion + Elapsed time.Duration `json:"elapsed,omitempty"` + AvgSpeed float64 `json:"avg_speed,omitempty"` + Completed bool `json:"completed,omitempty"` + + // Error + Err error `json:"-"` + + // Pause state + State *DownloadRecord `json:"-"` + + // Config echo + RateLimit int64 `json:"rate_limit,omitempty"` + RateLimitSet bool `json:"rate_limit_set,omitempty"` + Workers int `json:"workers,omitempty"` + MinChunkSize int64 `json:"min_chunk_size,omitempty"` + Mirrors []string `json:"mirrors,omitempty"` + + // Request + Headers map[string]string `json:"headers,omitempty"` + Path string `json:"path,omitempty"` + + // Batch + BatchEvents []DownloadEvent `json:"batch_events,omitempty"` + + // System + Message string `json:"message,omitempty"` +} + +func (m DownloadEvent) MarshalJSON() ([]byte, error) { + type Alias DownloadEvent + var errStr string + if m.Err != nil { + errStr = m.Err.Error() + } + return json.Marshal(&struct { + Alias + Err string `json:"error,omitempty"` + }{ + Alias: (Alias)(m), + Err: errStr, + }) +} + +func (m *DownloadEvent) UnmarshalJSON(data []byte) error { + type Alias DownloadEvent + aux := &struct { + *Alias + Err json.RawMessage `json:"error"` + }{ + Alias: (*Alias)(m), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + m.Err = nil + if len(aux.Err) > 0 { + var errStr string + if err := json.Unmarshal(aux.Err, &errStr); err == nil { + if errStr != "" { + m.Err = errors.New(errStr) + } + } else { + raw := string(aux.Err) + if raw != "" && raw != "null" { + m.Err = errors.New(raw) + } + } + } + return nil +} + +type BatchProgress []DownloadEvent + +const ( + EventTypeProgress = "progress" + EventTypeStarted = "started" + EventTypeComplete = "complete" + EventTypeError = "error" + EventTypePaused = "paused" + EventTypeResumed = "resumed" + EventTypeQueued = "queued" + EventTypeRemoved = "removed" + EventTypeRequest = "request" + EventTypeBatchRequest = "batch_request" + EventTypeBatchProgress = "batch_progress" + EventTypeSystem = "system" +) + +type SSEMessage struct { + Event string + Data []byte +} + +func EncodeSSEMessages(msg DownloadEvent) ([]SSEMessage, error) { + eventType := EventTypeToString(msg.Type) + if eventType == "" { + return nil, nil + } + + if msg.Type == EventBatchProgress { + return EncodeBatchProgress(msg.BatchEvents) + } + + data, err := json.Marshal(msg) + if err != nil { + return nil, err + } + return []SSEMessage{{ + Event: eventType, + Data: data, + }}, nil +} + +func EncodeBatchProgress(batch []DownloadEvent) ([]SSEMessage, error) { + frames := make([]SSEMessage, 0, len(batch)) + for _, p := range batch { + data, err := json.Marshal(p) + if err != nil { + return nil, err + } + frames = append(frames, SSEMessage{ + Event: EventTypeProgress, + Data: data, + }) + } + return frames, nil +} + +func EventTypeToString(t EventType) string { + switch t { + case EventStarted: + return EventTypeStarted + case EventProgress: + return EventTypeProgress + case EventComplete: + return EventTypeComplete + case EventPaused: + return EventTypePaused + case EventResumed: + return EventTypeResumed + case EventQueued: + return EventTypeQueued + case EventRemoved: + return EventTypeRemoved + case EventError: + return EventTypeError + case EventRequest: + return EventTypeRequest + case EventBatchRequest: + return EventTypeBatchRequest + case EventBatchProgress: + return EventTypeBatchProgress + case EventSystem: + return EventTypeSystem + default: + return "" + } +} + +func DecodeSSEMessage(data []byte) (DownloadEvent, error) { + var msg DownloadEvent + if err := json.Unmarshal(data, &msg); err != nil { + return msg, err + } + return msg, nil +} diff --git a/internal/engine/types/models.go b/internal/types/models.go similarity index 50% rename from internal/engine/types/models.go rename to internal/types/models.go index 9269d1765..e1de1e2d9 100644 --- a/internal/engine/types/models.go +++ b/internal/types/models.go @@ -32,56 +32,58 @@ func (t *Task) GobDecode(data []byte) error { return nil } -// DownloadState is the persisted snapshot used to resume a download. -type DownloadState struct { - ID string `json:"id"` - URLHash string `json:"url_hash"` - URL string `json:"url"` - DestPath string `json:"dest_path"` - TotalSize int64 `json:"total_size"` - Downloaded int64 `json:"downloaded"` - Tasks []Task `json:"tasks"` - Filename string `json:"filename"` - CreatedAt int64 `json:"created_at"` - PausedAt int64 `json:"paused_at"` - Elapsed int64 `json:"elapsed"` - Mirrors []string `json:"mirrors,omitempty"` +// DownloadRecord is the canonical representation of a download's static configuration, +// persistent state, and runtime options. It replaces DownloadState, DownloadEntry, and DownloadConfig. +type DownloadRecord struct { + // Identity & Core Info + ID string `json:"id"` + URLHash string `json:"url_hash"` + URL string `json:"url"` + Filename string `json:"filename"` + OutputPath string `json:"output_path"` + DestPath string `json:"dest_path"` + TotalSize int64 `json:"total_size"` + Downloaded int64 `json:"downloaded"` - ChunkBitmap []byte `json:"chunk_bitmap,omitempty"` - ActualChunkSize int64 `json:"actual_chunk_size,omitempty"` + // Status + Status string `json:"status"` + Error string `json:"error,omitempty"` - FileHash string `json:"file_hash,omitempty"` - RateLimit int64 `json:"rate_limit,omitempty"` - RateLimitSet bool `json:"rate_limit_set,omitempty"` + // Lifecycle Timestamps & Stats + CreatedAt int64 `json:"created_at"` + PausedAt int64 `json:"paused_at,omitempty"` + CompletedAt int64 `json:"completed_at,omitempty"` + TimeTaken int64 `json:"time_taken,omitempty"` + Elapsed int64 `json:"elapsed,omitempty"` + AvgSpeed float64 `json:"avg_speed,omitempty"` - Workers int `json:"workers,omitempty"` - MinChunkSize int64 `json:"min_chunk_size,omitempty"` -} + // Resume State (Persistent) + Tasks []Task `json:"tasks,omitempty"` + ChunkBitmap []byte `json:"chunk_bitmap,omitempty"` + ActualChunkSize int64 `json:"actual_chunk_size,omitempty"` + FileHash string `json:"file_hash,omitempty"` -// DownloadEntry is the durable record used for history and lifecycle recovery. -type DownloadEntry struct { - ID string `json:"id"` - URLHash string `json:"url_hash"` - URL string `json:"url"` - DestPath string `json:"dest_path"` - Filename string `json:"filename"` - Status string `json:"status"` - TotalSize int64 `json:"total_size"` - Downloaded int64 `json:"downloaded"` - CompletedAt int64 `json:"completed_at"` - TimeTaken int64 `json:"time_taken"` - AvgSpeed float64 `json:"avg_speed"` + // Configuration Options (Persistent) Mirrors []string `json:"mirrors,omitempty"` RateLimit int64 `json:"rate_limit,omitempty"` RateLimitSet bool `json:"rate_limit_set,omitempty"` + Workers int `json:"workers,omitempty"` + MinChunkSize int64 `json:"min_chunk_size,omitempty"` - Workers int `json:"workers,omitempty"` - MinChunkSize int64 `json:"min_chunk_size,omitempty"` + // Runtime / Transient Configuration (Not persisted) + IsResume bool `json:"-" gob:"-"` + ProgressCh chan<- DownloadEvent `json:"-" gob:"-"` + ProgressState interface{} `json:"-" gob:"-"` // typically *progress.DownloadProgress + Runtime *RuntimeConfig `json:"-" gob:"-"` + Headers map[string]string `json:"-" gob:"-"` + Limiter ByteLimiter `json:"-" gob:"-"` + IsExplicitCategory bool `json:"-" gob:"-"` + SupportsRange bool `json:"-" gob:"-"` } // MasterList holds all tracked downloads. type MasterList struct { - Downloads []DownloadEntry `json:"downloads"` + Downloads []DownloadRecord `json:"downloads"` } // DownloadStatus is the transient view returned to the TUI and API clients. @@ -114,3 +116,18 @@ type CancelResult struct { Completed bool WasQueued bool } + +type MirrorStatus struct { + URL string + Active bool + Error bool +} + +// ChunkStatus represents the status of a visualization chunk +type ChunkStatus int + +const ( + ChunkPending ChunkStatus = 0 // 00 + ChunkDownloading ChunkStatus = 1 // 01 + ChunkCompleted ChunkStatus = 2 // 10 (Bit 2 set) +) diff --git a/internal/utils/debug.go b/internal/utils/debug.go index a8e114214..3355a9514 100644 --- a/internal/utils/debug.go +++ b/internal/utils/debug.go @@ -15,6 +15,7 @@ import ( var ( debugFile *os.File debugOnce sync.Once + debugMu sync.Mutex logsDir atomic.Value // string ) @@ -34,6 +35,22 @@ func IsLoggingEnabled() bool { return ok && dir != "" } +// CloseDebug closes the debug log file and resets the logger state so that a +// subsequent ConfigureDebug call (e.g. in a test that redirects the logs dir) +// will open a fresh file. On Windows, an open file handle prevents the +// temporary directory from being removed by t.TempDir() cleanup. +func CloseDebug() { + debugMu.Lock() + defer debugMu.Unlock() + if debugFile != nil { + _ = debugFile.Close() + debugFile = nil + } + logsDir.Store("") + debugOnce = sync.Once{} +} + + // Debug writes a message to debug.log file in the configured directory func Debug(format string, args ...any) { // Internal fast path check without lock @@ -49,6 +66,9 @@ func Debug(format string, args ...any) { // Calculate timestamp only if we are actually logging timestamp := time.Now().Format("2006-01-02 15:04:05") + debugMu.Lock() + defer debugMu.Unlock() + // Ensure file is open (still needs once, but fast after first time) debugOnce.Do(func() { _ = os.MkdirAll(dir, 0o755) @@ -60,6 +80,7 @@ func Debug(format string, args ...any) { } } + // CleanupLogs removes old log files, keeping only the most recent retentionCount files func CleanupLogs(retentionCount int) { if retentionCount < 0 {