029f485e57
* fix(backend): bound tool installer downloads with timeouts The GitHub release and tarball install strategies downloaded archives with http.DefaultClient, which has no timeout: a slow or stalled mirror left an install hanging indefinitely with no way out. Both strategies now share a package-level client whose transport bounds each phase that can stall silently (connect, TLS handshake, response headers), and whose response bodies are wrapped in a stall guard that aborts the transfer when it stops making progress. There is deliberately no overall http.Client.Timeout — these are tarballs, and one deadline for the whole transfer would kill a large-but-healthy download on a slow link. Requests keep using http.NewRequestWithContext, so an install remains cancellable; the guard derives its own cancellable context from the caller's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(backend): bound only blocked reads in the download stall guard The guard armed its timer at construction and rearmed it after any read that returned bytes, so the window also covered whatever the caller did with those bytes downstream. Expanding one read's worth of a highly compressed archive onto a slow disk can outlast the window without the mirror having gone quiet at all, which would surface a false "download stalled" error on a perfectly healthy install. Arm the timer around the blocking read alone and stop it as soon as the read returns, so the bound is on how long a read blocks rather than on wall-clock time spent downstream. Reported by Codex review on #2016. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
138 lines
3.7 KiB
Go
138 lines
3.7 KiB
Go
package installer
|
|
|
|
import (
|
|
"compress/gzip"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// GithubReleaseConfig configures a GitHub release download.
|
|
type GithubReleaseConfig struct {
|
|
Owner string // e.g. "rust-lang"
|
|
Repo string // e.g. "rust-analyzer"
|
|
AssetPattern string // e.g. "rust-analyzer-{target}.gz"
|
|
Targets map[string]string // "darwin/arm64" -> "aarch64-apple-darwin"
|
|
}
|
|
|
|
// GithubReleaseStrategy downloads prebuilt binaries from GitHub releases.
|
|
type GithubReleaseStrategy struct {
|
|
binDir string
|
|
binary string // "rust-analyzer"
|
|
config GithubReleaseConfig
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewGithubReleaseStrategy creates a new GitHub release download strategy.
|
|
func NewGithubReleaseStrategy(binDir, binary string, config GithubReleaseConfig, log *logger.Logger) *GithubReleaseStrategy {
|
|
return &GithubReleaseStrategy{
|
|
binDir: binDir,
|
|
binary: binary,
|
|
config: config,
|
|
logger: log,
|
|
}
|
|
}
|
|
|
|
func (s *GithubReleaseStrategy) Name() string {
|
|
return fmt.Sprintf("github release %s/%s", s.config.Owner, s.config.Repo)
|
|
}
|
|
|
|
func (s *GithubReleaseStrategy) Install(ctx context.Context) (*InstallResult, error) {
|
|
// Resolve target from runtime OS/ARCH
|
|
targetKey := runtime.GOOS + "/" + runtime.GOARCH
|
|
target, ok := s.config.Targets[targetKey]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported platform: %s", targetKey)
|
|
}
|
|
|
|
// Build download URL
|
|
asset := strings.Replace(s.config.AssetPattern, "{target}", target, 1)
|
|
url := fmt.Sprintf("https://github.com/%s/%s/releases/latest/download/%s",
|
|
s.config.Owner, s.config.Repo, asset)
|
|
|
|
s.logger.Info("downloading from GitHub releases",
|
|
zap.String("url", url),
|
|
zap.String("target", target))
|
|
|
|
// Download
|
|
resp, err := getDownload(ctx, url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Ensure bin directory exists
|
|
if err := os.MkdirAll(s.binDir, 0o755); err != nil {
|
|
return nil, fmt.Errorf("failed to create directory %s: %w", s.binDir, err)
|
|
}
|
|
|
|
outPath := filepath.Join(s.binDir, s.binary)
|
|
|
|
// Decompress .gz or write directly
|
|
if strings.HasSuffix(asset, ".gz") {
|
|
if err := WriteGzipBody(resp.Body, outPath); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if err := WriteBody(resp.Body, outPath); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Make executable
|
|
if err := os.Chmod(outPath, 0o755); err != nil {
|
|
return nil, fmt.Errorf("failed to chmod: %w", err)
|
|
}
|
|
|
|
s.logger.Info("GitHub release download completed", zap.String("binary", outPath))
|
|
return &InstallResult{
|
|
BinaryPath: outPath,
|
|
}, nil
|
|
}
|
|
|
|
// WriteGzipBody decompresses a gzip reader into outPath.
|
|
func WriteGzipBody(body io.Reader, outPath string) error {
|
|
gzReader, err := gzip.NewReader(body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create gzip reader: %w", err)
|
|
}
|
|
defer func() { _ = gzReader.Close() }()
|
|
|
|
outFile, err := os.Create(outPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create output file: %w", err)
|
|
}
|
|
defer func() { _ = outFile.Close() }()
|
|
|
|
if _, err := io.Copy(outFile, gzReader); err != nil {
|
|
return fmt.Errorf("failed to decompress: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteBody writes a plain reader into outPath.
|
|
func WriteBody(body io.Reader, outPath string) error {
|
|
outFile, err := os.Create(outPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create output file: %w", err)
|
|
}
|
|
defer func() { _ = outFile.Close() }()
|
|
|
|
if _, err := io.Copy(outFile, body); err != nil {
|
|
return fmt.Errorf("failed to write binary: %w", err)
|
|
}
|
|
return nil
|
|
}
|