Skip to content

Commit c2608f3

Browse files
committed
Harden settings tunnel supervision
1 parent e4051c1 commit c2608f3

2 files changed

Lines changed: 150 additions & 9 deletions

File tree

internal/tunnel/manager.go

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"log"
7+
"runtime"
78
"sort"
89
"strconv"
910
"strings"
@@ -17,6 +18,13 @@ const (
1718
StatusStopped = "stopped"
1819
)
1920

21+
var settingsTunnelRetryBackoff = []time.Duration{
22+
2 * time.Second,
23+
5 * time.Second,
24+
10 * time.Second,
25+
30 * time.Second,
26+
}
27+
2028
type ConfigRecord struct {
2129
ID string `json:"id"`
2230
Name string `json:"name"`
@@ -63,6 +71,7 @@ type Manager struct {
6371
appTunnelEnabled bool
6472
defaultEngine string
6573
tunnels map[string]*managedTunnel
74+
retryBackoff []time.Duration
6675
}
6776

6877
func NewManager(store Store, appPort string, appTunnelEnabled bool, defaultEngine string) *Manager {
@@ -83,6 +92,7 @@ func NewManagerWithStarter(store Store, appPort string, appTunnelEnabled bool, d
8392
appTunnelEnabled: appTunnelEnabled,
8493
defaultEngine: engine,
8594
tunnels: make(map[string]*managedTunnel),
95+
retryBackoff: settingsTunnelRetryBackoff,
8696
}
8797
}
8898

@@ -393,8 +403,16 @@ func (m *Manager) startAsync(id string) {
393403
_ = runtimeToStop.Stop()
394404
}
395405

396-
go func() {
406+
go m.startLoop(id, seq, cfg)
407+
}
408+
409+
func (m *Manager) startLoop(id string, seq uint64, cfg ConfigRecord) {
410+
attempt := 0
411+
for {
397412
runtime, err := m.startTunnel(cfg.Engine, cfg.LocalPort)
413+
if err == nil && runtime == nil {
414+
err = errors.New("tunnel starter returned nil runtime")
415+
}
398416

399417
m.mu.Lock()
400418
current, ok := m.tunnels[id]
@@ -405,19 +423,59 @@ func (m *Manager) startAsync(id string) {
405423
}
406424
return
407425
}
408-
if err != nil {
409-
current.status = StatusStopped
410-
current.lastError = err.Error()
426+
if err == nil {
427+
current.runtime = runtime
428+
current.status = StatusStarted
429+
current.lastError = ""
411430
m.mu.Unlock()
412-
log.Printf("tunnel: failed to start %s on port %s: %v", current.config.Name, current.config.LocalPort, err)
413431
return
414432
}
415433

416-
current.runtime = runtime
417-
current.status = StatusStarted
418-
current.lastError = ""
434+
current.runtime = nil
435+
current.status = StatusStarting
436+
current.lastError = err.Error()
437+
name := current.config.Name
438+
port := current.config.LocalPort
439+
delay := retryDelay(m.retryBackoff, attempt)
440+
attempt++
419441
m.mu.Unlock()
420-
}()
442+
443+
if runtime != nil {
444+
_ = runtime.Stop()
445+
}
446+
log.Printf("tunnel: failed to start %s on port %s: %v; retrying in %s", name, port, err, delay)
447+
if !m.waitForRetry(id, seq, delay) {
448+
return
449+
}
450+
}
451+
}
452+
453+
func (m *Manager) waitForRetry(id string, seq uint64, delay time.Duration) bool {
454+
if delay <= 0 {
455+
runtime.Gosched()
456+
return m.shouldContinueStart(id, seq)
457+
}
458+
timer := time.NewTimer(delay)
459+
defer timer.Stop()
460+
<-timer.C
461+
return m.shouldContinueStart(id, seq)
462+
}
463+
464+
func (m *Manager) shouldContinueStart(id string, seq uint64) bool {
465+
m.mu.RLock()
466+
defer m.mu.RUnlock()
467+
current, ok := m.tunnels[id]
468+
return ok && current.launchSeq == seq && current.config.Enabled
469+
}
470+
471+
func retryDelay(backoff []time.Duration, attempt int) time.Duration {
472+
if len(backoff) == 0 {
473+
return time.Second
474+
}
475+
if attempt < len(backoff) {
476+
return backoff[attempt]
477+
}
478+
return backoff[len(backoff)-1]
421479
}
422480

423481
func (m *Manager) snapshotPortsLocked(excludeID string) map[string]string {

internal/tunnel/manager_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,86 @@ func TestManagerLoadsEnabledTunnels(t *testing.T) {
143143

144144
t.Fatal("expected enabled tunnel to auto-start")
145145
}
146+
147+
func TestManagerRetriesEnabledTunnelUntilStartSucceeds(t *testing.T) {
148+
store := &fakeStore{}
149+
startCalls := 0
150+
manager := NewManagerWithStarter(store, "8080", false, "cloudflare", func(engine, port string) (RuntimeTunnel, error) {
151+
startCalls++
152+
if startCalls < 3 {
153+
return nil, errors.New("temporary tunnel failure")
154+
}
155+
return &fakeRuntime{url: "https://retry.trycloudflare.com"}, nil
156+
})
157+
manager.retryBackoff = []time.Duration{5 * time.Millisecond}
158+
159+
record, err := manager.Save(ConfigRecord{
160+
Name: "Retrying Preview",
161+
LocalPort: "5173",
162+
})
163+
if err != nil {
164+
t.Fatalf("Save returned error: %v", err)
165+
}
166+
if _, err := manager.Start(record.ID); err != nil {
167+
t.Fatalf("Start returned error: %v", err)
168+
}
169+
170+
deadline := time.Now().Add(time.Second)
171+
for time.Now().Before(deadline) {
172+
current, ok := manager.Get(record.ID)
173+
if ok && current.Status == StatusStarted && current.URL == "https://retry.trycloudflare.com" {
174+
if startCalls != 3 {
175+
t.Fatalf("expected 3 start attempts, got %d", startCalls)
176+
}
177+
return
178+
}
179+
time.Sleep(5 * time.Millisecond)
180+
}
181+
182+
t.Fatalf("expected tunnel to retry until started; attempts=%d", startCalls)
183+
}
184+
185+
func TestManagerStopCancelsPendingRetry(t *testing.T) {
186+
store := &fakeStore{}
187+
started := make(chan struct{}, 1)
188+
startCalls := 0
189+
manager := NewManagerWithStarter(store, "8080", false, "cloudflare", func(engine, port string) (RuntimeTunnel, error) {
190+
startCalls++
191+
started <- struct{}{}
192+
return nil, errors.New("temporary tunnel failure")
193+
})
194+
manager.retryBackoff = []time.Duration{25 * time.Millisecond}
195+
196+
record, err := manager.Save(ConfigRecord{
197+
Name: "Stop Retry",
198+
LocalPort: "3333",
199+
})
200+
if err != nil {
201+
t.Fatalf("Save returned error: %v", err)
202+
}
203+
if _, err := manager.Start(record.ID); err != nil {
204+
t.Fatalf("Start returned error: %v", err)
205+
}
206+
207+
select {
208+
case <-started:
209+
case <-time.After(time.Second):
210+
t.Fatal("expected first start attempt")
211+
}
212+
213+
if _, err := manager.Stop(record.ID); err != nil {
214+
t.Fatalf("Stop returned error: %v", err)
215+
}
216+
time.Sleep(75 * time.Millisecond)
217+
218+
if startCalls != 1 {
219+
t.Fatalf("expected stop to cancel retry loop, got %d start attempts", startCalls)
220+
}
221+
current, ok := manager.Get(record.ID)
222+
if !ok {
223+
t.Fatal("expected tunnel record")
224+
}
225+
if current.Status != StatusStopped {
226+
t.Fatalf("expected stopped status, got %q", current.Status)
227+
}
228+
}

0 commit comments

Comments
 (0)