From f0a8e29722198b30cc2fcdf4361d68fab50d5422 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Fri, 14 Aug 2026 19:04:28 +0100 Subject: [PATCH] fix(mac): a cached ETag is not an answer to "what server should I install" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureRuntime resolved the runtime to install through the same release client the background update check uses, which replays GitHub's ETag. A 304 therefore came back as an error and surfaced as could not install the cix server: could not find a cix server to install: not modified with setup dead in the water. The ETag describes GitHub's release listing — the same listing for every Mac in the world — and says nothing about whether this machine has a runtime on disk, so the two questions must not share a cache. It bricked the install permanently rather than transiently: the first launch runs one background update check, which persists the ETag to ~/.cix/launcher.json, and that file outlives both the process and a reinstall of the app. Any launch after a first run that did not complete setup answered every install attempt with 304, and kept doing so until somebody published a new server release. latestRuntime now separates them: on a 304 it takes the listing this session already saw, and when the ETag came from an earlier run — so the body it matched is gone — it re-asks without it, on a copy of the client so the cached ETag survives for the cheap checks it exists for. Co-Authored-By: Claude Opus 5 --- cli/launcher/runtime_darwin.go | 2 +- cli/launcher/runtime_darwin_test.go | 96 +++++++++++++++++++++++++++++ cli/launcher/update_darwin.go | 36 +++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/cli/launcher/runtime_darwin.go b/cli/launcher/runtime_darwin.go index 924656a..2286552 100644 --- a/cli/launcher/runtime_darwin.go +++ b/cli/launcher/runtime_darwin.go @@ -536,7 +536,7 @@ func ensureRuntime(u *updater, progress func(string)) error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - rel, err := u.runtime.Latest(ctx) + rel, err := u.latestRuntime(ctx) if err != nil { return fmt.Errorf("could not find a cix server to install: %w", err) } diff --git a/cli/launcher/runtime_darwin_test.go b/cli/launcher/runtime_darwin_test.go index 6e7d433..dabe26d 100644 --- a/cli/launcher/runtime_darwin_test.go +++ b/cli/launcher/runtime_darwin_test.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "compress/gzip" + "context" "crypto/sha256" "encoding/hex" "io" @@ -466,3 +467,98 @@ func TestUnpackRuntimeRejectsIncompleteTree(t *testing.T) { t.Error("the version directory was created despite the incomplete tree") } } + +// serveStreamListing serves the releases API for the server/v* stream, honouring +// If-None-Match the way GitHub does. Returns the base URL and a counter of the +// 200s it answered. +func serveStreamListing(t *testing.T, version, etag string) (base string, full *int) { + t.Helper() + + served := 0 + mux := http.NewServeMux() + mux.HandleFunc("/repos/dvcdsys/code-index/releases", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + served++ + w.Header().Set("ETag", etag) + _, _ = io.WriteString(w, `[{"tag_name":"`+serverTagPrefix+version+`","html_url":"https://example.invalid","assets":[]}]`) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv.URL, &served +} + +// A cached ETag says the release *listing* has not changed. It says nothing +// about whether this machine has a runtime, so an install must not read a 304 as +// "there is no server to install" — that turned every launch after the first +// into a permanently unsetuppable app, since the ETag is persisted in +// launcher.json and survives reinstalling. +func TestLatestRuntimeIgnoresAStaleETag(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + const etag = `"cached-from-a-previous-run"` + base, served := serveStreamListing(t, "1.2.3", etag) + t.Setenv("CIX_UPDATE_BASE_URL", base) + + // What the previous run left behind: an ETag, and no memory of the body it + // matched. + if err := savePrefs(prefs{RuntimeETag: etag}); err != nil { + t.Fatal(err) + } + + u := newUpdater(bundle{}) + rel, err := u.latestRuntime(context.Background()) + if err != nil { + t.Fatalf("latestRuntime: %v", err) + } + if rel.Version != "1.2.3" { + t.Errorf("version = %q, want 1.2.3", rel.Version) + } + if *served != 1 { + t.Errorf("full listings served = %d, want 1 (the retry without the ETag)", *served) + } + // The retry runs on a copy: the cached ETag is what keeps the routine checks + // off GitHub's rate limit, and an install must not spend it. + if u.runtime.ETag != etag { + t.Errorf("cached ETag = %q, want it left at %q", u.runtime.ETag, etag) + } + if p := loadPrefs(); p.RuntimeETag != etag { + t.Errorf("persisted ETag = %q, want it left at %q", p.RuntimeETag, etag) + } +} + +// When this process already listed the stream, the 304 is confirming that +// listing — no second request needed. +func TestLatestRuntimeReusesTheListingThisSessionSaw(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + const etag = `"seen-this-session"` + base, served := serveStreamListing(t, "2.0.0", etag) + t.Setenv("CIX_UPDATE_BASE_URL", base) + + u := newUpdater(bundle{}) + // A background check, exactly as onReady runs one: 200, and the ETag is now + // cached both in memory and on disk. It lists twice — the app stream and the + // server stream are separate clients over the same endpoint. + u.check(true) + if u.seenRuntime.Version != "2.0.0" { + t.Fatalf("seenRuntime = %q, want 2.0.0", u.seenRuntime.Version) + } + afterCheck := *served + + rel, err := u.latestRuntime(context.Background()) + if err != nil { + t.Fatalf("latestRuntime: %v", err) + } + if rel.Version != "2.0.0" { + t.Errorf("version = %q, want 2.0.0", rel.Version) + } + if *served != afterCheck { + t.Errorf("full listings served = %d, want %d — the 304 should have been answered from memory", + *served, afterCheck) + } +} diff --git a/cli/launcher/update_darwin.go b/cli/launcher/update_darwin.go index 7d994d7..708c40c 100644 --- a/cli/launcher/update_darwin.go +++ b/cli/launcher/update_darwin.go @@ -137,6 +137,42 @@ func (u *updater) check(force bool) available { return av } +// latestRuntime answers "what server should I install", as opposed to "is there +// a newer server than the one installed". +// +// The difference is what a 304 means. u.runtime replays an ETag so that an +// update *check* costs nothing on the days nothing was published, and there +// "not modified" correctly reads as "no news, keep what you had". An install is +// a different question, and the ETag says nothing about whether this machine +// has a runtime on disk: it describes GitHub's release listing, which is the +// same for every Mac in the world. +// +// Conflating the two bricked first-run setup. The ETag outlives the process in +// launcher.json, so any machine whose first launch completed one background +// update check — every second launch, in other words — answered the install +// query with 304, reported it as "could not find a cix server to install: not +// modified", and kept doing so through retries and reinstalls until somebody +// published a new server release. +func (u *updater) latestRuntime(ctx context.Context) (release.Release, error) { + rel, err := u.runtime.Latest(ctx) + if !errors.Is(err, release.ErrNotModified) { + return rel, err + } + // The listing this process already saw is the one GitHub is declining to + // resend, so it is the right answer and costs no request. + if u.seenRuntime.Version != "" { + logf("runtime listing unchanged; installing the release this session already saw") + return u.seenRuntime, nil + } + // The ETag came from an earlier run, so the body it matched is gone. Ask + // again without it — on a copy, so the cached ETag stays intact for the + // cheap checks it exists for. + logf("runtime listing unchanged but nothing cached in this session; re-asking without the ETag") + fresh := *u.runtime + fresh.ETag = "" + return fresh.Latest(ctx) +} + // refresh polls one stream, keeping the previous answer when nothing came back. func (u *updater) refresh(what string, c *release.Client, previous release.Release, setETag func(*prefs, string)) release.Release { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)