Skip to content

Commit 8edb16e

Browse files
authored
Fix Google self-heal durability: drop impossible OSID requirement, persist rotated cookies, pace repair (#148)
The in-process Google Messages cookie self-heal required a messages.google.com:OSID cookie that does not exist on Chrome profiles that never opened Messages-for-web, so every credential repair failed and the supervisor parked in needs_repair forever. Live-proven fix: the session revives on the five .google.com account cookies alone. - googlecookies + refresh scripts: require only SID/HSID/SSID/APISID/SAPISID; carry messages.google.com OSID when present, never require it; deterministic host tie-break. - internal/client/events.go: persist libgm's rotated cookies to session.json (sha256 change-detect, 5-min throttle, CookiesLock-guarded) so restarts resume fresh. - cmd/google_supervisor.go: pace automatic repairs >= a min interval (default 90s, OPENMESSAGE_REPAIR_MIN_INTERVAL) to prevent churn/throttle if fast revocation ever occurs; delays rather than parks; pacedCount doubles as a fast-revocation detector. Live-verified on the daily-driver install: a real auth_expired self-healed via cookie import in ~2 min, needs_repair never latched over ~15h (99% connected). Cross-family reviewed (sol) GO for merge + deploy.
1 parent 5f50c95 commit 8edb16e

9 files changed

Lines changed: 605 additions & 32 deletions

cmd/google_supervisor.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"math/rand"
99
"os"
1010
"sync"
11+
"sync/atomic"
1112
"time"
1213

1314
"github.com/rs/zerolog"
@@ -19,8 +20,13 @@ import (
1920
const (
2021
googleAccountID = "google-primary"
2122
googleSupervisorStopTimeout = 30 * time.Second
23+
// Ninety seconds rate-limits minutes-scale cookie-revocation churn while
24+
// remaining transparent to legitimate expiry, measured at >= about 14 minutes.
25+
googleCredentialRepairDefaultMinInterval = 90 * time.Second
2226
)
2327

28+
const googleCredentialRepairMinIntervalEnv = "OPENMESSAGE_REPAIR_MIN_INTERVAL"
29+
2430
func googleSupervisorPolicy() bridge.Policy {
2531
return bridge.Policy{
2632
ConnectTimeout: 2*time.Minute + 30*time.Second,
@@ -56,6 +62,13 @@ type googleCredentialRepairer struct {
5662
refresh func(context.Context, string) error
5763
flagRepair func()
5864
clearRepair func()
65+
minInterval time.Duration
66+
now func() time.Time
67+
68+
mu sync.Mutex
69+
paceGate chan struct{}
70+
lastRepairAt time.Time
71+
pacedCount atomic.Uint64
5972
}
6073

6174
func newGoogleCredentialRepairer(
@@ -71,14 +84,94 @@ func newGoogleCredentialRepairer(
7184
refresh: refresh,
7285
flagRepair: flagRepair,
7386
clearRepair: clearRepair,
87+
minInterval: googleCredentialRepairMinInterval(),
88+
}
89+
}
90+
91+
func googleCredentialRepairMinInterval() time.Duration {
92+
value := os.Getenv(googleCredentialRepairMinIntervalEnv)
93+
interval, err := time.ParseDuration(value)
94+
if value == "" || err != nil || interval <= 0 {
95+
return googleCredentialRepairDefaultMinInterval
96+
}
97+
return interval
98+
}
99+
100+
func (r *googleCredentialRepairer) PacedRepairCount() uint64 {
101+
return r.pacedCount.Load()
102+
}
103+
104+
func (r *googleCredentialRepairer) repairNow() time.Time {
105+
if r.now != nil {
106+
return r.now()
107+
}
108+
return time.Now()
109+
}
110+
111+
func (r *googleCredentialRepairer) acquireRepairCooldown(
112+
ctx context.Context,
113+
) (func(), error) {
114+
r.mu.Lock()
115+
if r.paceGate == nil {
116+
r.paceGate = make(chan struct{}, 1)
117+
r.paceGate <- struct{}{}
118+
}
119+
paceGate := r.paceGate
120+
r.mu.Unlock()
121+
122+
select {
123+
case <-paceGate:
124+
if err := ctx.Err(); err != nil {
125+
paceGate <- struct{}{}
126+
return nil, err
127+
}
128+
return func() { paceGate <- struct{}{} }, nil
129+
case <-ctx.Done():
130+
return nil, ctx.Err()
131+
}
132+
}
133+
134+
func (r *googleCredentialRepairer) waitForRepairCooldown(ctx context.Context) error {
135+
release, err := r.acquireRepairCooldown(ctx)
136+
if err != nil {
137+
return err
138+
}
139+
defer release()
140+
141+
r.mu.Lock()
142+
now := r.repairNow()
143+
lastRepairAt := r.lastRepairAt
144+
r.mu.Unlock()
145+
elapsed := now.Sub(lastRepairAt)
146+
if r.minInterval > 0 && !lastRepairAt.IsZero() && elapsed < r.minInterval {
147+
wait := r.minInterval - elapsed
148+
r.pacedCount.Add(1)
149+
150+
select {
151+
case <-time.After(wait):
152+
case <-ctx.Done():
153+
return ctx.Err()
154+
}
155+
if err := ctx.Err(); err != nil {
156+
return err
157+
}
74158
}
159+
160+
r.mu.Lock()
161+
r.lastRepairAt = r.repairNow()
162+
r.mu.Unlock()
163+
return nil
75164
}
76165

77166
func (r *googleCredentialRepairer) RepairCredentials(
78167
ctx context.Context,
79168
_ string,
80169
_ bridge.OpError,
81170
) error {
171+
if err := r.waitForRepairCooldown(ctx); err != nil {
172+
return err
173+
}
174+
82175
if r.canRepair == nil || !r.canRepair() || r.refresh == nil {
83176
if r.flagRepair != nil {
84177
r.flagRepair()

cmd/google_supervisor_test.go

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,213 @@ func TestGoogleSupervisorCredentialRepairControlsGenerationStart(t *testing.T) {
120120
}
121121
}
122122

123+
func TestGoogleCredentialRepairMinInterval(t *testing.T) {
124+
tests := []struct {
125+
name string
126+
value string
127+
want time.Duration
128+
}{
129+
{
130+
name: "unset",
131+
want: googleCredentialRepairDefaultMinInterval,
132+
},
133+
{
134+
name: "invalid",
135+
value: "soon",
136+
want: googleCredentialRepairDefaultMinInterval,
137+
},
138+
{
139+
name: "non-positive",
140+
value: "0s",
141+
want: googleCredentialRepairDefaultMinInterval,
142+
},
143+
{
144+
name: "configured",
145+
value: "5m",
146+
want: 5 * time.Minute,
147+
},
148+
}
149+
150+
for _, tc := range tests {
151+
t.Run(tc.name, func(t *testing.T) {
152+
t.Setenv(googleCredentialRepairMinIntervalEnv, tc.value)
153+
if got := googleCredentialRepairMinInterval(); got != tc.want {
154+
t.Fatalf("googleCredentialRepairMinInterval() = %s, want %s", got, tc.want)
155+
}
156+
})
157+
}
158+
}
159+
160+
func TestGoogleCredentialRepairCooldownFirstRepairProceedsImmediately(t *testing.T) {
161+
clock := &googleCredentialRepairTestClock{}
162+
var refreshCalls atomic.Int32
163+
repairer := &googleCredentialRepairer{
164+
sessionPath: "session.json",
165+
canRepair: func() bool { return true },
166+
refresh: func(context.Context, string) error {
167+
refreshCalls.Add(1)
168+
return nil
169+
},
170+
minInterval: time.Hour,
171+
now: clock.Now,
172+
}
173+
174+
if err := repairer.RepairCredentials(context.Background(), "", bridge.OpError{}); err != nil {
175+
t.Fatalf("RepairCredentials() error = %v", err)
176+
}
177+
if got := refreshCalls.Load(); got != 1 {
178+
t.Fatalf("refresh calls = %d, want 1", got)
179+
}
180+
if got := repairer.PacedRepairCount(); got != 0 {
181+
t.Fatalf("paced repair count = %d, want 0", got)
182+
}
183+
}
184+
185+
func TestGoogleCredentialRepairCooldownPacesTooSoonRepair(t *testing.T) {
186+
const minInterval = 50 * time.Millisecond
187+
clock := &googleCredentialRepairTestClock{}
188+
var refreshMu sync.Mutex
189+
var refreshTimes []time.Time
190+
repairer := &googleCredentialRepairer{
191+
sessionPath: "session.json",
192+
canRepair: func() bool { return true },
193+
refresh: func(context.Context, string) error {
194+
refreshMu.Lock()
195+
refreshTimes = append(refreshTimes, clock.Now())
196+
refreshMu.Unlock()
197+
return nil
198+
},
199+
minInterval: minInterval,
200+
now: clock.Now,
201+
}
202+
203+
if err := repairer.RepairCredentials(context.Background(), "", bridge.OpError{}); err != nil {
204+
t.Fatalf("first RepairCredentials() error = %v", err)
205+
}
206+
repairer.mu.Lock()
207+
firstRepairAt := repairer.lastRepairAt
208+
repairer.mu.Unlock()
209+
210+
if err := repairer.RepairCredentials(context.Background(), "", bridge.OpError{}); err != nil {
211+
t.Fatalf("second RepairCredentials() error = %v", err)
212+
}
213+
refreshMu.Lock()
214+
gotRefreshTimes := append([]time.Time(nil), refreshTimes...)
215+
refreshMu.Unlock()
216+
if len(gotRefreshTimes) != 2 {
217+
t.Fatalf("refresh calls = %d, want 2", len(gotRefreshTimes))
218+
}
219+
if elapsed := gotRefreshTimes[1].Sub(firstRepairAt); elapsed < minInterval {
220+
t.Fatalf("second refresh elapsed from first repair = %s, want >= %s", elapsed, minInterval)
221+
}
222+
if got := repairer.PacedRepairCount(); got != 1 {
223+
t.Fatalf("paced repair count = %d, want 1", got)
224+
}
225+
}
226+
227+
func TestGoogleCredentialRepairCooldownAfterIntervalProceedsImmediately(t *testing.T) {
228+
const minInterval = time.Minute
229+
clock := &googleCredentialRepairTestClock{}
230+
var refreshCalls atomic.Int32
231+
repairer := &googleCredentialRepairer{
232+
sessionPath: "session.json",
233+
canRepair: func() bool { return true },
234+
refresh: func(context.Context, string) error {
235+
refreshCalls.Add(1)
236+
return nil
237+
},
238+
minInterval: minInterval,
239+
now: clock.Now,
240+
}
241+
242+
if err := repairer.RepairCredentials(context.Background(), "", bridge.OpError{}); err != nil {
243+
t.Fatalf("first RepairCredentials() error = %v", err)
244+
}
245+
clock.Advance(minInterval)
246+
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
247+
defer cancel()
248+
if err := repairer.RepairCredentials(ctx, "", bridge.OpError{}); err != nil {
249+
t.Fatalf("second RepairCredentials() after interval error = %v", err)
250+
}
251+
if got := refreshCalls.Load(); got != 2 {
252+
t.Fatalf("refresh calls = %d, want 2", got)
253+
}
254+
if got := repairer.PacedRepairCount(); got != 0 {
255+
t.Fatalf("paced repair count = %d, want 0", got)
256+
}
257+
}
258+
259+
func TestGoogleCredentialRepairCooldownCancellationDoesNotFlagOrRefresh(t *testing.T) {
260+
clock := &googleCredentialRepairTestClock{}
261+
var refreshCalls atomic.Int32
262+
var flagCalls atomic.Int32
263+
repairer := &googleCredentialRepairer{
264+
sessionPath: "session.json",
265+
canRepair: func() bool { return true },
266+
refresh: func(context.Context, string) error {
267+
refreshCalls.Add(1)
268+
return nil
269+
},
270+
flagRepair: func() { flagCalls.Add(1) },
271+
minInterval: time.Hour,
272+
now: clock.Now,
273+
}
274+
275+
if err := repairer.RepairCredentials(context.Background(), "", bridge.OpError{}); err != nil {
276+
t.Fatalf("first RepairCredentials() error = %v", err)
277+
}
278+
ctx, cancel := context.WithCancel(context.Background())
279+
errCh := make(chan error, 1)
280+
go func() {
281+
errCh <- repairer.RepairCredentials(ctx, "", bridge.OpError{})
282+
}()
283+
awaitGooglePacedRepairCount(t, repairer, 1)
284+
if got := refreshCalls.Load(); got != 1 {
285+
t.Fatalf("refresh calls before cancellation = %d, want 1", got)
286+
}
287+
288+
queuedBaseCtx, queuedCancel := context.WithCancel(context.Background())
289+
queuedCtx := &googleRepairObservedContext{
290+
Context: queuedBaseCtx,
291+
doneCalled: make(chan struct{}),
292+
}
293+
queuedErrCh := make(chan error, 1)
294+
go func() {
295+
queuedErrCh <- repairer.RepairCredentials(queuedCtx, "", bridge.OpError{})
296+
}()
297+
select {
298+
case <-queuedCtx.doneCalled:
299+
case <-time.After(time.Second):
300+
t.Fatal("queued RepairCredentials() did not begin waiting for admission")
301+
}
302+
queuedCancel()
303+
select {
304+
case err := <-queuedErrCh:
305+
if !errors.Is(err, context.Canceled) {
306+
t.Fatalf("queued RepairCredentials() error = %v, want context.Canceled", err)
307+
}
308+
case <-time.After(time.Second):
309+
t.Fatal("queued RepairCredentials() did not return after cancellation")
310+
}
311+
312+
cancel()
313+
314+
select {
315+
case err := <-errCh:
316+
if !errors.Is(err, context.Canceled) {
317+
t.Fatalf("RepairCredentials() error = %v, want context.Canceled", err)
318+
}
319+
case <-time.After(time.Second):
320+
t.Fatal("RepairCredentials() did not return after cancellation")
321+
}
322+
if got := refreshCalls.Load(); got != 1 {
323+
t.Fatalf("refresh calls after cancellation = %d, want 1", got)
324+
}
325+
if got := flagCalls.Load(); got != 0 {
326+
t.Fatalf("needs_repair flag calls = %d, want 0", got)
327+
}
328+
}
329+
123330
func TestGoogleSupervisorManualReconnectUsesOwnedCommands(t *testing.T) {
124331
sessionPath := filepath.Join(t.TempDir(), "session.json")
125332
if err := os.WriteFile(sessionPath, []byte("session-v1"), 0o600); err != nil {
@@ -338,3 +545,48 @@ func awaitGoogleSupervisorGenerationState(
338545
}
339546
}
340547
}
548+
549+
type googleCredentialRepairTestClock struct {
550+
offset atomic.Int64
551+
}
552+
553+
type googleRepairObservedContext struct {
554+
context.Context
555+
doneCalled chan struct{}
556+
once sync.Once
557+
}
558+
559+
func (c *googleRepairObservedContext) Done() <-chan struct{} {
560+
c.once.Do(func() { close(c.doneCalled) })
561+
return c.Context.Done()
562+
}
563+
564+
func (c *googleCredentialRepairTestClock) Now() time.Time {
565+
return time.Now().Add(time.Duration(c.offset.Load()))
566+
}
567+
568+
func (c *googleCredentialRepairTestClock) Advance(delta time.Duration) {
569+
c.offset.Add(int64(delta))
570+
}
571+
572+
func awaitGooglePacedRepairCount(
573+
t *testing.T,
574+
repairer *googleCredentialRepairer,
575+
want uint64,
576+
) {
577+
t.Helper()
578+
deadline := time.NewTimer(time.Second)
579+
defer deadline.Stop()
580+
ticker := time.NewTicker(time.Millisecond)
581+
defer ticker.Stop()
582+
for {
583+
if got := repairer.PacedRepairCount(); got == want {
584+
return
585+
}
586+
select {
587+
case <-deadline.C:
588+
t.Fatalf("paced repair count = %d, want %d", repairer.PacedRepairCount(), want)
589+
case <-ticker.C:
590+
}
591+
}
592+
}

0 commit comments

Comments
 (0)