diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 5c14a77c5..5deca704a 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -140,7 +140,7 @@ function GlobalUpdateProgressWatcher() { } else if (!inProgress && hasAutoOpened()) { setHasAutoOpened(false); } - } catch (error) { + } catch (_error) { // Silently ignore polling errors } }; diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 9c9f77ba9..9309ee7e7 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -2061,7 +2061,7 @@ const Settings: Component = (props) => { showError('Invalid backup file format. Expected encrypted data in "data" field.'); return; } - } catch (parseError) { + } catch (_parseError) { // Not JSON - treat entire contents as raw base64 from CLI export encryptedData = fileContent.trim(); } diff --git a/frontend-modern/src/stores/metricsViewMode.ts b/frontend-modern/src/stores/metricsViewMode.ts index dc40af65e..4d0df7edd 100644 --- a/frontend-modern/src/stores/metricsViewMode.ts +++ b/frontend-modern/src/stores/metricsViewMode.ts @@ -19,7 +19,7 @@ const getInitialViewMode = (): MetricsViewMode => { if (stored === 'sparklines' || stored === 'bars') { return stored; } - } catch (err) { + } catch (_err) { // Ignore localStorage errors } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 2832ac309..a3a56a3f3 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -188,6 +188,13 @@ func (rw *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { return hijacker.Hijack() } +// Flush implements http.Flusher when the underlying writer supports it. +func (rw *responseWriter) Flush() { + if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + // NewAPIError creates a new API error func NewAPIError(statusCode int, code, message string) error { return &APIError{ diff --git a/internal/updates/manager.go b/internal/updates/manager.go index d1b75919a..40dad8b7c 100644 --- a/internal/updates/manager.go +++ b/internal/updates/manager.go @@ -15,6 +15,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "sync" "time" @@ -58,7 +59,11 @@ type UpdateInfo struct { Warning string `json:"warning,omitempty"` } -var errGitHubRateLimited = errors.New("GitHub API rate limit exceeded") +var ( + errGitHubRateLimited = errors.New("GitHub API rate limit exceeded") + stageDelayOnce sync.Once + stageDelayValue time.Duration +) // Manager handles update operations type Manager struct { @@ -1137,6 +1142,10 @@ func (m *Manager) updateStatus(status string, progress int, message string, err if m.sseBroadcast != nil { m.sseBroadcast.Broadcast(statusCopy) } + + if delay := statusDelayForStage(status); delay > 0 { + time.Sleep(delay) + } } // sseHeartbeatLoop sends periodic heartbeats to SSE clients @@ -1168,6 +1177,37 @@ func sanitizeError(err error) string { return errMsg } +func statusDelayForStage(status string) time.Duration { + delay := configuredStageDelay() + if delay == 0 { + return 0 + } + + switch status { + case "downloading", "verifying", "extracting", "backing-up", "applying": + return delay + default: + return 0 + } +} + +func configuredStageDelay() time.Duration { + stageDelayOnce.Do(func() { + value := strings.TrimSpace(os.Getenv("PULSE_UPDATE_STAGE_DELAY_MS")) + if value == "" { + return + } + ms, err := strconv.Atoi(value) + if err != nil || ms <= 0 { + log.Warn().Str("value", value).Msg("Invalid PULSE_UPDATE_STAGE_DELAY_MS, ignoring") + return + } + stageDelayValue = time.Duration(ms) * time.Millisecond + }) + + return stageDelayValue +} + // cleanupOldTempDirs removes old pulse-update-* temp directories from previous runs func (m *Manager) cleanupOldTempDirs() { // Check multiple locations where temp dirs might exist diff --git a/tests/integration/api/update_flow_test.go b/tests/integration/api/update_flow_test.go index 98ad5aeca..2b862b30b 100644 --- a/tests/integration/api/update_flow_test.go +++ b/tests/integration/api/update_flow_test.go @@ -181,7 +181,7 @@ func waitForCompletion(t *testing.T, client *http.Client, baseURL string, timeou if time.Now().After(deadline) { t.Fatalf("update did not complete within %s (last status: %+v)", timeout, status) } - time.Sleep(500 * time.Millisecond) + time.Sleep(100 * time.Millisecond) } } diff --git a/tests/integration/docker-compose.test.yml b/tests/integration/docker-compose.test.yml index 6bf13d4db..ff5b6df85 100644 --- a/tests/integration/docker-compose.test.yml +++ b/tests/integration/docker-compose.test.yml @@ -41,6 +41,7 @@ services: # Mock mode for faster testing - PULSE_MOCK_MODE=true - PULSE_ALLOW_DOCKER_UPDATES=true + - PULSE_UPDATE_STAGE_DELAY_MS=250 # Pre-configure authentication to bypass first-run setup - PULSE_AUTH_USER=admin - PULSE_AUTH_PASS=admin diff --git a/tests/integration/mock-github-server/main.go b/tests/integration/mock-github-server/main.go index 14237d7fa..02a4914eb 100644 --- a/tests/integration/mock-github-server/main.go +++ b/tests/integration/mock-github-server/main.go @@ -57,6 +57,15 @@ func getenvDefault(key, fallback string) string { return fallback } +func isChecksumFilename(name string) bool { + switch strings.ToLower(name) { + case "checksums.txt", "sha256sums", "sha256sums.txt": + return true + default: + return false + } +} + func (rl *rateLimiter) cleanup() { rl.mu.Lock() defer rl.mu.Unlock() @@ -122,6 +131,7 @@ func main() { // In-memory storage for tarballs and checksums tarballs := make(map[string][]byte) checksums := make(map[string]string) + checksumEntries := make(map[string][]string) latestTag := getenvDefault("MOCK_LATEST_VERSION", "v99.0.0") prevTag := getenvDefault("MOCK_PREVIOUS_VERSION", "v98.5.0") @@ -169,6 +179,9 @@ func main() { } checksums[filename] = checksum + entry := fmt.Sprintf("%s %s", checksum, filename) + checksumEntries[version] = append(checksumEntries[version], entry) + checksumEntries["v"+version] = append(checksumEntries["v"+version], entry) // Add download URLs to release rel.Assets = []struct { @@ -242,27 +255,44 @@ func main() { version := parts[0] file := parts[1] - if file == "checksums.txt" { + if isChecksumFilename(file) { // Generate checksums.txt var buf bytes.Buffer - for fname, chksum := range checksums { - if strings.Contains(fname, version) { - buf.WriteString(fmt.Sprintf("%s %s\n", chksum, fname)) + entries, ok := checksumEntries[version] + if !ok { + trimmed := strings.TrimPrefix(version, "v") + entries = checksumEntries[trimmed] + } + if len(entries) == 0 { + w.WriteHeader(http.StatusNotFound) + log.Printf("No checksums found for version %s", version) + return + } + for _, line := range entries { + buf.WriteString(line) + if !strings.HasSuffix(line, "\n") { + buf.WriteByte('\n') } } w.Header().Set("Content-Type", "text/plain") w.Write(buf.Bytes()) - log.Printf("Served checksums for version %s", version) + log.Printf("Served checksums for version %s (requested %s)", version, file) return } - // Serve tarball - filename := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", version) - tarball, ok := tarballs[filename] + // Serve tarball (strictly match requested filename first) + tarball, ok := tarballs[file] if !ok { - w.WriteHeader(http.StatusNotFound) - log.Printf("Tarball not found: %s", filename) - return + // Fallback to canonical filename derived from version (without leading v) + trimmedVersion := strings.TrimPrefix(version, "v") + canonical := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", trimmedVersion) + tarball, ok = tarballs[canonical] + if !ok { + w.WriteHeader(http.StatusNotFound) + log.Printf("Tarball not found: %s (canonical %s)", file, canonical) + return + } + file = canonical } // Mark as stale if requested @@ -274,7 +304,7 @@ func main() { w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Length", strconv.Itoa(len(tarball))) w.Write(tarball) - log.Printf("Served tarball: %s", filename) + log.Printf("Served tarball: %s (version %s)", file, version) }) // Health check