🐛 fix(update): 避免旧镜像遮蔽开发版更新

This commit is contained in:
Syngnat
2026-07-23 14:45:31 +08:00
parent 1dcc866846
commit 830403b04b
2 changed files with 384 additions and 9 deletions

View File

@@ -61,16 +61,28 @@ type updateNetworkCheckMemory struct {
}
var (
updateFetchStaticManifest = fetchStaticUpdateManifest
updateNetworkCheckMu sync.Mutex
updateLastNetworkCheck updateNetworkCheckMemory
updateFetchStaticManifest = fetchStaticUpdateManifest
updateFetchFreshestStaticManifest = fetchFreshestStaticUpdateManifest
updateNetworkCheckMu sync.Mutex
updateLastNetworkCheck updateNetworkCheckMemory
)
func swapUpdateFetchStaticManifest(next func(updateChannel) (*githubRelease, error)) func() {
original := updateFetchStaticManifest
originalStatic := updateFetchStaticManifest
originalFreshest := updateFetchFreshestStaticManifest
updateFetchStaticManifest = next
updateFetchFreshestStaticManifest = next
return func() {
updateFetchStaticManifest = original
updateFetchStaticManifest = originalStatic
updateFetchFreshestStaticManifest = originalFreshest
}
}
func swapUpdateFetchFreshestStaticManifest(next func(updateChannel) (*githubRelease, error)) func() {
original := updateFetchFreshestStaticManifest
updateFetchFreshestStaticManifest = next
return func() {
updateFetchFreshestStaticManifest = original
}
}
@@ -226,6 +238,10 @@ func fetchStaticUpdateManifest(channel updateChannel) (*githubRelease, error) {
return fetchStaticUpdateManifestFromURLs(channel, updateManifestRemoteURLs(channel))
}
func fetchFreshestStaticUpdateManifest(channel updateChannel) (*githubRelease, error) {
return fetchFreshestStaticUpdateManifestFromURLs(channel, updateManifestRemoteURLs(channel))
}
func fetchStaticUpdateManifestFromURLs(channel updateChannel, manifestURLs []string) (*githubRelease, error) {
var failures []string
for _, manifestURL := range manifestURLs {
@@ -244,6 +260,97 @@ func fetchStaticUpdateManifestFromURLs(channel updateChannel, manifestURLs []str
}
func fetchStaticUpdateManifestFromURL(channel updateChannel, manifestURL string) (*githubRelease, error) {
manifest, err := fetchStaticUpdateManifestPayloadFromURL(channel, manifestURL)
if err != nil {
return nil, err
}
return commitStaticUpdateManifest(channel, manifest), nil
}
func fetchFreshestStaticUpdateManifestFromURLs(channel updateChannel, manifestURLs []string) (*githubRelease, error) {
type fetchResult struct {
index int
manifest *updateReleaseManifest
err error
}
results := make([]fetchResult, len(manifestURLs))
resultCh := make(chan fetchResult, len(manifestURLs))
for index, manifestURL := range manifestURLs {
go func(index int, manifestURL string) {
manifest, err := fetchStaticUpdateManifestPayloadFromURL(channel, manifestURL)
resultCh <- fetchResult{index: index, manifest: manifest, err: err}
}(index, manifestURL)
}
for range manifestURLs {
result := <-resultCh
results[result.index] = result
}
var selected *updateReleaseManifest
var failures []string
for index, result := range results {
if result.err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", manifestURLs[index], result.err))
continue
}
if shouldPreferUpdateManifest(channel, selected, result.manifest) {
selected = result.manifest
}
}
if selected != nil {
return commitStaticUpdateManifest(channel, selected), nil
}
if len(failures) == 0 {
return nil, fmt.Errorf("no static update manifest URL configured")
}
return nil, fmt.Errorf("static update manifests unavailable: %s", strings.Join(failures, "; "))
}
func shouldPreferUpdateManifest(channel updateChannel, current *updateReleaseManifest, candidate *updateReleaseManifest) bool {
if candidate == nil {
return false
}
if current == nil {
return true
}
comparison, comparable := compareUpdateManifestRecency(channel, current, candidate)
return comparable && comparison < 0
}
// compareUpdateManifestRecency follows compareVersion: -1 means current is older.
// A false comparable result keeps source order stable, so the mirror wins ties.
func compareUpdateManifestRecency(channel updateChannel, current *updateReleaseManifest, candidate *updateReleaseManifest) (int, bool) {
if current == nil || candidate == nil {
return 0, false
}
if channel != updateChannelDev {
currentVersion := normalizeVersion(firstNonEmptyString(current.Version, current.TagName))
candidateVersion := normalizeVersion(firstNonEmptyString(candidate.Version, candidate.TagName))
if currentVersion != "" && candidateVersion != "" {
if comparison := compareVersion(currentVersion, candidateVersion); comparison != 0 {
return comparison, true
}
}
}
currentPublishedAt, currentErr := time.Parse(time.RFC3339, strings.TrimSpace(current.PublishedAt))
candidatePublishedAt, candidateErr := time.Parse(time.RFC3339, strings.TrimSpace(candidate.PublishedAt))
if currentErr == nil && candidateErr == nil {
switch {
case currentPublishedAt.Before(candidatePublishedAt):
return -1, true
case currentPublishedAt.After(candidatePublishedAt):
return 1, true
default:
return 0, true
}
}
return 0, false
}
func fetchStaticUpdateManifestPayloadFromURL(channel updateChannel, manifestURL string) (*updateReleaseManifest, error) {
url := strings.TrimSpace(manifestURL)
if url == "" {
return nil, fmt.Errorf("static update manifest URL is empty")
@@ -292,15 +399,22 @@ func fetchStaticUpdateManifestFromURL(channel updateChannel, manifestURL string)
if strings.TrimSpace(manifest.Channel) == "" {
manifest.Channel = string(channel)
}
storeDiskUpdateManifest(channel, &manifest)
release := releaseFromUpdateManifest(&manifest)
return &manifest, nil
}
func commitStaticUpdateManifest(channel updateChannel, manifest *updateReleaseManifest) *githubRelease {
if manifest == nil {
return nil
}
storeDiskUpdateManifest(channel, manifest)
release := releaseFromUpdateManifest(manifest)
// 同步进进程内 API 缓存,供限流回退
if channel == updateChannelDev {
storeCachedGitHubRelease(updateDevAPIURL, release)
} else {
storeCachedGitHubRelease(updateLatestAPIURL, release)
}
return release, nil
return release
}
func validateRemoteUpdateManifest(channel updateChannel, manifest *updateReleaseManifest) error {
@@ -383,8 +497,13 @@ func fetchReleaseForChannelPreferringStatic(channel updateChannel, forceNetwork
}
}
staticFetcher := updateFetchStaticManifest
if forceNetwork {
staticFetcher = updateFetchFreshestStaticManifest
}
var staticErr error
if release, err := updateFetchStaticManifest(channel); err == nil && release != nil {
if release, err := staticFetcher(channel); err == nil && release != nil {
markUpdateNetworkCheck(channel)
return release, nil
} else {

View File

@@ -17,6 +17,72 @@ import (
"time"
)
func configureUpdateManifestHTTPTest(t *testing.T) {
t.Helper()
for _, name := range []string{
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "all_proxy",
} {
t.Setenv(name, "")
}
t.Setenv("NO_PROXY", "127.0.0.1,localhost")
t.Setenv("no_proxy", "127.0.0.1,localhost")
t.Setenv("GONAVI_DATA_ROOT", t.TempDir())
}
func buildValidUpdateManifestForTest(
t *testing.T,
channel updateChannel,
version string,
publishedAt string,
hashCharacter string,
) updateReleaseManifest {
t.Helper()
expectedAsset, err := expectedAssetNameForInstallMode(
stdRuntime.GOOS,
stdRuntime.GOARCH,
version,
updateResolveInstallMode(),
)
if err != nil {
t.Fatalf("resolve expected update asset: %v", err)
}
tagName := "v" + normalizeVersion(version)
name := tagName
if channel == updateChannelDev {
tagName = updateDevReleaseTag
name = "Dev Build (" + version + ")"
}
return updateReleaseManifest{
SchemaVersion: updateManifestSchemaVersion,
Channel: string(channel),
TagName: tagName,
Version: version,
Name: name,
PublishedAt: publishedAt,
Assets: []updateManifestAsset{{
Name: expectedAsset,
URL: "https://download.example.test/" + expectedAsset,
APIURL: "https://github.example.test/" + expectedAsset,
Size: 123,
SHA256: strings.Repeat(hashCharacter, 64),
}},
}
}
func serveUpdateManifestForTest(t *testing.T, manifest updateReleaseManifest, hits *atomic.Int32) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if hits != nil {
hits.Add(1)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(manifest); err != nil {
t.Errorf("encode manifest: %v", err)
}
}))
}
func TestDownloadUpdateAssetWithFallbackRetriesChecksumMismatch(t *testing.T) {
goodPayload := []byte("verified update package")
expectedHash := fmt.Sprintf("%x", sha256.Sum256(goodPayload))
@@ -186,6 +252,153 @@ func TestFetchStaticUpdateManifestFromURLsFallsBackFromInvalidR2Manifests(t *tes
}
}
func TestFetchFreshestStaticUpdateManifestFromURLsPrefersNewerDevPublishedAt(t *testing.T) {
configureUpdateManifestHTTPTest(t)
var mirrorHits atomic.Int32
var githubHits atomic.Int32
mirror := serveUpdateManifestForTest(t, buildValidUpdateManifestForTest(
t, updateChannelDev, "dev-3e02c81", "2026-07-23T01:19:41Z", "a",
), &mirrorHits)
defer mirror.Close()
github := serveUpdateManifestForTest(t, buildValidUpdateManifestForTest(
t, updateChannelDev, "dev-83952ab", "2026-07-23T05:39:03Z", "b",
), &githubHits)
defer github.Close()
release, err := fetchFreshestStaticUpdateManifestFromURLs(updateChannelDev, []string{mirror.URL, github.URL})
if err != nil {
t.Fatalf("fetch freshest static manifest: %v", err)
}
if got := resolveReleaseVersion(updateChannelDev, release); got != "dev-83952ab" {
t.Fatalf("freshest dev version = %q, want dev-83952ab", got)
}
if mirrorHits.Load() != 1 || githubHits.Load() != 1 {
t.Fatalf("manifest request counts: mirror=%d github=%d", mirrorHits.Load(), githubHits.Load())
}
cached, _ := loadDiskUpdateManifest(updateChannelDev)
if cached == nil || cached.Version != "dev-83952ab" {
t.Fatalf("cached manifest = %#v, want selected GitHub manifest", cached)
}
}
func TestFetchFreshestStaticUpdateManifestFromURLsUsesAvailableSource(t *testing.T) {
configureUpdateManifestHTTPTest(t)
tests := []struct {
name string
mirrorAvailable bool
githubAvailable bool
wantVersion string
}{
{name: "mirror unavailable", githubAvailable: true, wantVersion: "dev-github"},
{name: "github unavailable", mirrorAvailable: true, wantVersion: "dev-mirror"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
mirror := serveUpdateManifestForTest(t, buildValidUpdateManifestForTest(
t, updateChannelDev, "dev-mirror", "2026-07-23T01:00:00Z", "a",
), nil)
mirrorURL := mirror.URL
if !test.mirrorAvailable {
mirror.Close()
} else {
defer mirror.Close()
}
github := serveUpdateManifestForTest(t, buildValidUpdateManifestForTest(
t, updateChannelDev, "dev-github", "2026-07-23T02:00:00Z", "b",
), nil)
githubURL := github.URL
if !test.githubAvailable {
github.Close()
} else {
defer github.Close()
}
release, err := fetchFreshestStaticUpdateManifestFromURLs(updateChannelDev, []string{mirrorURL, githubURL})
if err != nil {
t.Fatalf("fetch freshest static manifest: %v", err)
}
if got := resolveReleaseVersion(updateChannelDev, release); got != test.wantVersion {
t.Fatalf("version = %q, want %q", got, test.wantVersion)
}
})
}
}
func TestFetchFreshestStaticUpdateManifestFromURLsPrefersHigherStableVersion(t *testing.T) {
configureUpdateManifestHTTPTest(t)
mirror := serveUpdateManifestForTest(t, buildValidUpdateManifestForTest(
t, updateChannelLatest, "2.4.9", "2026-07-23T05:00:00Z", "c",
), nil)
defer mirror.Close()
github := serveUpdateManifestForTest(t, buildValidUpdateManifestForTest(
t, updateChannelLatest, "2.5.0", "2026-07-23T04:00:00Z", "d",
), nil)
defer github.Close()
release, err := fetchFreshestStaticUpdateManifestFromURLs(updateChannelLatest, []string{mirror.URL, github.URL})
if err != nil {
t.Fatalf("fetch freshest stable manifest: %v", err)
}
if got := resolveReleaseVersion(updateChannelLatest, release); got != "2.5.0" {
t.Fatalf("stable version = %q, want 2.5.0", got)
}
}
func TestFetchFreshestStaticUpdateManifestFromURLsFetchesSourcesConcurrently(t *testing.T) {
configureUpdateManifestHTTPTest(t)
entered := make(chan struct{}, 2)
releaseHandlers := make(chan struct{})
serveBlockedManifest := func(manifest updateReleaseManifest) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
entered <- struct{}{}
<-releaseHandlers
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(manifest)
}))
}
mirror := serveBlockedManifest(buildValidUpdateManifestForTest(
t, updateChannelDev, "dev-mirror", "2026-07-23T01:00:00Z", "e",
))
defer mirror.Close()
github := serveBlockedManifest(buildValidUpdateManifestForTest(
t, updateChannelDev, "dev-github", "2026-07-23T02:00:00Z", "f",
))
defer github.Close()
type fetchOutcome struct {
release *githubRelease
err error
}
done := make(chan fetchOutcome, 1)
go func() {
release, err := fetchFreshestStaticUpdateManifestFromURLs(updateChannelDev, []string{mirror.URL, github.URL})
done <- fetchOutcome{release: release, err: err}
}()
for index := 0; index < 2; index++ {
select {
case <-entered:
case <-time.After(2 * time.Second):
close(releaseHandlers)
t.Fatal("static manifest sources were not fetched concurrently")
}
}
close(releaseHandlers)
outcome := <-done
if outcome.err != nil {
t.Fatalf("fetch freshest static manifest: %v", outcome.err)
}
if got := resolveReleaseVersion(updateChannelDev, outcome.release); got != "dev-github" {
t.Fatalf("version = %q, want dev-github", got)
}
}
func TestDiskUpdateManifestRoundTrip(t *testing.T) {
root := t.TempDir()
t.Setenv("GONAVI_DATA_ROOT", root)
@@ -249,6 +462,49 @@ func TestFetchReleaseForChannelPreferringStaticUsesStaticFirst(t *testing.T) {
}
}
func TestFetchReleaseForChannelPreferringStaticUsesFreshestOnlyForManualChecks(t *testing.T) {
t.Setenv("GONAVI_DATA_ROOT", t.TempDir())
updateReleaseCache = sync.Map{}
updateNetworkCheckMu.Lock()
updateLastNetworkCheck = updateNetworkCheckMemory{}
updateNetworkCheckMu.Unlock()
staticCalls := 0
freshestCalls := 0
restoreStatic := swapUpdateFetchStaticManifest(func(channel updateChannel) (*githubRelease, error) {
staticCalls++
return &githubRelease{TagName: updateDevReleaseTag, Name: "Dev Build (dev-mirror)"}, nil
})
defer restoreStatic()
restoreFreshest := swapUpdateFetchFreshestStaticManifest(func(channel updateChannel) (*githubRelease, error) {
freshestCalls++
return &githubRelease{TagName: updateDevReleaseTag, Name: "Dev Build (dev-github)"}, nil
})
defer restoreFreshest()
silentRelease, err := fetchReleaseForChannelPreferringStatic(updateChannelDev, false)
if err != nil {
t.Fatalf("silent static fetch: %v", err)
}
if got := resolveReleaseVersion(updateChannelDev, silentRelease); got != "dev-mirror" {
t.Fatalf("silent version = %q, want mirror", got)
}
if staticCalls != 1 || freshestCalls != 0 {
t.Fatalf("silent calls: static=%d freshest=%d", staticCalls, freshestCalls)
}
manualRelease, err := fetchReleaseForChannelPreferringStatic(updateChannelDev, true)
if err != nil {
t.Fatalf("manual static fetch: %v", err)
}
if got := resolveReleaseVersion(updateChannelDev, manualRelease); got != "dev-github" {
t.Fatalf("manual version = %q, want freshest", got)
}
if staticCalls != 1 || freshestCalls != 1 {
t.Fatalf("manual calls: static=%d freshest=%d", staticCalls, freshestCalls)
}
}
func TestFetchReleaseForChannelPreferringStaticFallsBackAPIThenDisk(t *testing.T) {
root := t.TempDir()
t.Setenv("GONAVI_DATA_ROOT", root)