Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/launcher/runtime_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
96 changes: 96 additions & 0 deletions cli/launcher/runtime_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"io"
Expand Down Expand Up @@ -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)
}
}
36 changes: 36 additions & 0 deletions cli/launcher/update_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down