From 0480f2aba3048989ef7d676ad526d81c05cad44b Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 5 Jun 2026 23:54:11 -0400 Subject: [PATCH 1/3] fix(hover): preserve warm probe cancellation Keep canceled or expired warm-session probes from falling through to the slower browser login path. Use atomic counters in warm-profile tests so httptest handler assertions stay race-free. --- pkg/hoverclient/browser_backend.go | 6 +++ pkg/hoverclient/browser_backend_test.go | 59 +++++++++++++++++-------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/pkg/hoverclient/browser_backend.go b/pkg/hoverclient/browser_backend.go index 9309734..00140b5 100644 --- a/pkg/hoverclient/browser_backend.go +++ b/pkg/hoverclient/browser_backend.go @@ -306,6 +306,12 @@ func (b *browserBackend) probeExistingSession(ctx context.Context, c *Client) (b req.Header.Set("User-Agent", c.UserAgent) resp, err := c.do(req) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false, err + } + if ctxErr := ctx.Err(); ctxErr != nil { + return false, ctxErr + } return false, nil } defer resp.Body.Close() diff --git a/pkg/hoverclient/browser_backend_test.go b/pkg/hoverclient/browser_backend_test.go index e672e2a..92fd737 100644 --- a/pkg/hoverclient/browser_backend_test.go +++ b/pkg/hoverclient/browser_backend_test.go @@ -16,6 +16,7 @@ import ( "net/url" "os" "strings" + "sync/atomic" "testing" "time" @@ -477,10 +478,10 @@ func TestBrowserBackend_LoginSkipsWhenFresh(t *testing.T) { opts := newBrowserTestOpts(t) // Count how many times the signin page is hit. - signinHits := 0 + var signinHits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { - signinHits++ + signinHits.Add(1) http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fake", Path: "/"}) _, _ = w.Write([]byte(`signin`)) }) @@ -502,7 +503,7 @@ func TestBrowserBackend_LoginSkipsWhenFresh(t *testing.T) { if err := c.Login(ctx); err != nil { t.Fatalf("first Login: %v", err) } - firstHits := signinHits + firstHits := signinHits.Load() // Second login: session is fresh — should not re-navigate. if err := c.Login(ctx); err != nil { @@ -510,18 +511,18 @@ func TestBrowserBackend_LoginSkipsWhenFresh(t *testing.T) { } // Signin page must not have been hit again. - if signinHits != firstHits { - t.Errorf("browser re-launched on second Login; signin page hit count went from %d to %d", firstHits, signinHits) + if got := signinHits.Load(); got != firstHits { + t.Errorf("browser re-launched on second Login; signin page hit count went from %d to %d", firstHits, got) } } func TestBrowserBackend_LoginReusesWarmBrowserProfileSession(t *testing.T) { opts := newBrowserTestOpts(t) - var signinHits, authHits, domainsHits int + var signinHits, authHits, domainsHits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) { - domainsHits++ + domainsHits.Add(1) if _, err := r.Cookie("__uzma"); err != nil { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"succeeded":false,"error_code":"login"}`)) @@ -531,12 +532,12 @@ func TestBrowserBackend_LoginReusesWarmBrowserProfileSession(t *testing.T) { _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "domains": []map[string]any{}}) }) mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { - signinHits++ + signinHits.Add(1) http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fresh-from-signin", Path: "/"}) _, _ = w.Write([]byte(`signin`)) }) mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { - authHits++ + authHits.Add(1) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"}) }) @@ -555,10 +556,10 @@ func TestBrowserBackend_LoginReusesWarmBrowserProfileSession(t *testing.T) { if err := c.Login(ctx); err != nil { t.Fatalf("Login with warm profile: %v", err) } - if signinHits != 0 || authHits != 0 { - t.Fatalf("warm profile should skip credential login; signinHits=%d authHits=%d", signinHits, authHits) + if gotSignin, gotAuth := signinHits.Load(), authHits.Load(); gotSignin != 0 || gotAuth != 0 { + t.Fatalf("warm profile should skip credential login; signinHits=%d authHits=%d", gotSignin, gotAuth) } - if domainsHits == 0 { + if domainsHits.Load() == 0 { t.Fatal("warm profile login did not probe /api/domains") } c.mu.Lock() @@ -569,23 +570,43 @@ func TestBrowserBackend_LoginReusesWarmBrowserProfileSession(t *testing.T) { } } +func TestBrowserBackend_ProbeExistingSessionReturnsContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + bb := newBrowserBackend(BrowserOptions{}) + bb.overrideHost = "http://127.0.0.1:1" + c := &Client{ + http: &http.Client{}, + UserAgent: defaultUserAgent, + } + + ok, err := bb.probeExistingSession(ctx, c) + if ok { + t.Fatal("probeExistingSession unexpectedly reused a canceled session") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("probeExistingSession error = %v, want context.Canceled", err) + } +} + func TestBrowserBackend_LoginFallsBackWhenWarmProfileUnauthenticated(t *testing.T) { opts := newBrowserTestOpts(t) - var signinHits, authHits, domainsHits int + var signinHits, authHits, domainsHits atomic.Int32 mux := http.NewServeMux() mux.HandleFunc("/api/domains", func(w http.ResponseWriter, r *http.Request) { - domainsHits++ + domainsHits.Add(1) w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"succeeded":false,"error_code":"login"}`)) }) mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) { - signinHits++ + signinHits.Add(1) http.SetCookie(w, &http.Cookie{Name: "__uzma", Value: "fresh-from-signin", Path: "/"}) _, _ = w.Write([]byte(`signin`)) }) mux.HandleFunc("/signin/auth.json", func(w http.ResponseWriter, r *http.Request) { - authHits++ + authHits.Add(1) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{"succeeded": true, "status": "completed"}) }) @@ -603,11 +624,11 @@ func TestBrowserBackend_LoginFallsBackWhenWarmProfileUnauthenticated(t *testing. if err := c.Login(ctx); err != nil { t.Fatalf("Login after stale warm profile probe: %v", err) } - if domainsHits == 0 { + if domainsHits.Load() == 0 { t.Fatal("stale profile login did not probe /api/domains before fallback") } - if signinHits == 0 || authHits == 0 { - t.Fatalf("stale profile should fall back to credential login; signinHits=%d authHits=%d", signinHits, authHits) + if gotSignin, gotAuth := signinHits.Load(), authHits.Load(); gotSignin == 0 || gotAuth == 0 { + t.Fatalf("stale profile should fall back to credential login; signinHits=%d authHits=%d", gotSignin, gotAuth) } } From c01007c15ceb8466398e6ef588b8a30f1e978743 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 6 Jun 2026 00:03:19 -0400 Subject: [PATCH 2/3] chore(hover): bump plugin v0.5.7 --- plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin.json b/plugin.json index 9f6593d..aea1dc9 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "workflow-plugin-hover", - "version": "0.5.6", + "version": "0.5.7", "description": "Hover DNS provider (browser-style login + TOTP, no official SDK)", "author": "GoCodeAlone", "license": "MIT", From 8d9d59bcf8e60c77b557f1305c2634508485cce5 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 6 Jun 2026 00:18:18 -0400 Subject: [PATCH 3/3] fix(hover): preserve probe body cancellation --- pkg/hoverclient/browser_backend.go | 9 +++++ pkg/hoverclient/browser_backend_test.go | 52 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/pkg/hoverclient/browser_backend.go b/pkg/hoverclient/browser_backend.go index 00140b5..e21ad8a 100644 --- a/pkg/hoverclient/browser_backend.go +++ b/pkg/hoverclient/browser_backend.go @@ -316,12 +316,21 @@ func (b *browserBackend) probeExistingSession(ctx context.Context, c *Client) (b } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if ctxErr := ctx.Err(); ctxErr != nil { + return false, ctxErr + } return false, nil } var body struct { Succeeded bool `json:"succeeded"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false, err + } + if ctxErr := ctx.Err(); ctxErr != nil { + return false, ctxErr + } return false, nil } return body.Succeeded, nil diff --git a/pkg/hoverclient/browser_backend_test.go b/pkg/hoverclient/browser_backend_test.go index 92fd737..5ee8f8d 100644 --- a/pkg/hoverclient/browser_backend_test.go +++ b/pkg/hoverclient/browser_backend_test.go @@ -590,6 +590,34 @@ func TestBrowserBackend_ProbeExistingSessionReturnsContextCancellation(t *testin } } +func TestBrowserBackend_ProbeExistingSessionReturnsContextCancellationFromBodyRead(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + bb := newBrowserBackend(BrowserOptions{}) + bb.overrideHost = "https://example.test" + c := &Client{ + http: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: &cancelAfterPartialJSONBody{cancel: cancel}, + Header: make(http.Header), + Request: req, + }, nil + }), + }, + UserAgent: defaultUserAgent, + } + + ok, err := bb.probeExistingSession(ctx, c) + if ok { + t.Fatal("probeExistingSession unexpectedly reused a canceled session") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("probeExistingSession error = %v, want context.Canceled", err) + } +} + func TestBrowserBackend_LoginFallsBackWhenWarmProfileUnauthenticated(t *testing.T) { opts := newBrowserTestOpts(t) @@ -632,6 +660,30 @@ func TestBrowserBackend_LoginFallsBackWhenWarmProfileUnauthenticated(t *testing. } } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type cancelAfterPartialJSONBody struct { + cancel context.CancelFunc + sent bool +} + +func (b *cancelAfterPartialJSONBody) Read(p []byte) (int, error) { + if !b.sent { + b.sent = true + return copy(p, `{"succeeded":`), nil + } + b.cancel() + return 0, context.Canceled +} + +func (b *cancelAfterPartialJSONBody) Close() error { + return nil +} + func seedBrowserProfileCookie(t *testing.T, opts BrowserOptions, baseURL, name, value string) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)