Skip to content

Commit 9b3a7dc

Browse files
wesmclaude
andauthored
fix: graceful shutdown on ctrl-c and remove auto-open browser (#164)
## Summary - Wire the signal context as `BaseContext` on `http.Server` so Ctrl-C cancels all request contexts immediately, letting SSE handlers exit and `Shutdown` complete promptly - Thread `context.Context` through `SyncAll`/`ResyncAll` so API-triggered syncs abort on cancellation instead of blocking shutdown - Abort the resync DB swap when cancelled mid-run, preventing a partial temp DB from replacing the original - Skip post-cancel phases (OpenCode sync, skip-cache persist) in `syncAllLocked` when aborted - Remove auto-open browser feature; keep `-no-browser` flag registered as a no-op for script compatibility ## Test plan - [x] New regression test: cancel `ResyncAll` mid-run, verify original DB preserved - [x] `make test-short` passes - [x] `make vet` passes - [x] Manual: `./agentsview` responds to first Ctrl-C immediately 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent aae2d5d commit 9b3a7dc

File tree

13 files changed

+437
-149
lines changed

13 files changed

+437
-149
lines changed

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,8 @@ patterns over time.
6363
## Usage
6464

6565
```bash
66-
agentsview # start server, open browser
66+
agentsview # start server
6767
agentsview -port 9090 # custom port
68-
agentsview -no-browser # headless mode
6968
```
7069

7170
On startup, agentsview discovers sessions from all supported

cmd/agentsview/main.go

Lines changed: 26 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,8 @@ import (
99
"log"
1010
"net/http"
1111
"os"
12-
"os/exec"
1312
"os/signal"
1413
"path/filepath"
15-
"runtime"
1614
"syscall"
1715
"time"
1816
_ "time/tzdata"
@@ -34,8 +32,6 @@ const (
3432
periodicSyncInterval = 15 * time.Minute
3533
unwatchedPollInterval = 2 * time.Minute
3634
watcherDebounce = 500 * time.Millisecond
37-
browserPollInterval = 100 * time.Millisecond
38-
browserPollAttempts = 60
3935
)
4036

4137
func main() {
@@ -94,7 +90,6 @@ Server flags:
9490
-tls-cert string TLS certificate path for managed Caddy HTTPS mode
9591
-tls-key string TLS key path for managed Caddy HTTPS mode
9692
-allowed-subnet str Client CIDR allowed to connect to the managed proxy
97-
-no-browser Don't open browser on startup
9893
9994
Sync flags:
10095
-full Force a full resync regardless of data version
@@ -190,16 +185,24 @@ func runServe(args []string) {
190185
// Remove stale temp DB from a prior crashed resync.
191186
cleanResyncTemp(cfg.DBPath)
192187

188+
ctx, stop := signal.NotifyContext(
189+
context.Background(), os.Interrupt, syscall.SIGTERM,
190+
)
191+
defer stop()
192+
193193
engine := sync.NewEngine(database, sync.EngineConfig{
194194
AgentDirs: cfg.AgentDirs,
195195
Machine: "local",
196196
BlockedResultCategories: cfg.ResultContentBlockedCategories,
197197
})
198198

199199
if database.NeedsResync() {
200-
runInitialResync(engine)
200+
runInitialResync(ctx, engine)
201201
} else {
202-
runInitialSync(engine)
202+
runInitialSync(ctx, engine)
203+
}
204+
if ctx.Err() != nil {
205+
return
203206
}
204207

205208
stopWatcher, unwatchedDirs := startFileWatcher(cfg, engine)
@@ -257,13 +260,9 @@ func runServe(args []string) {
257260
BuildDate: buildDate,
258261
}),
259262
server.WithDataDir(cfg.DataDir),
263+
server.WithBaseContext(ctx),
260264
)
261265

262-
ctx, stop := signal.NotifyContext(
263-
context.Background(), os.Interrupt, syscall.SIGTERM,
264-
)
265-
defer stop()
266-
267266
serveErrCh := make(chan error, 1)
268267
go func() {
269268
serveErrCh <- srv.ListenAndServe()
@@ -342,10 +341,6 @@ func runServe(args []string) {
342341
)
343342
}
344343

345-
if !cfg.NoBrowser {
346-
go openBrowser(publicURL)
347-
}
348-
349344
var caddyErrCh <-chan error
350345
if caddy != nil {
351346
caddyErrCh = caddy.Err()
@@ -480,26 +475,30 @@ func cleanResyncTemp(dbPath string) {
480475
}
481476
}
482477

483-
func runInitialSync(engine *sync.Engine) {
478+
func runInitialSync(
479+
ctx context.Context, engine *sync.Engine,
480+
) {
484481
fmt.Println("Running initial sync...")
485482
t := time.Now()
486-
stats := engine.SyncAll(printSyncProgress)
483+
stats := engine.SyncAll(ctx, printSyncProgress)
487484
printSyncSummary(stats, t)
488485
}
489486

490-
func runInitialResync(engine *sync.Engine) {
487+
func runInitialResync(
488+
ctx context.Context, engine *sync.Engine,
489+
) {
491490
fmt.Println("Data version changed, running full resync...")
492491
t := time.Now()
493-
stats := engine.ResyncAll(printSyncProgress)
492+
stats := engine.ResyncAll(ctx, printSyncProgress)
494493
printSyncSummary(stats, t)
495494

496-
// If resync was aborted (swap didn't happen), fall back
497-
// to a normal incremental sync so the server starts with
498-
// current file data rather than a potentially stale DB.
499-
if stats.Aborted {
495+
// If resync was aborted due to data issues (not
496+
// cancellation), fall back to an incremental sync so
497+
// the server starts with current data.
498+
if stats.Aborted && ctx.Err() == nil {
500499
fmt.Println("Resync incomplete, running incremental sync...")
501500
t = time.Now()
502-
fallback := engine.SyncAll(printSyncProgress)
501+
fallback := engine.SyncAll(ctx, printSyncProgress)
503502
printSyncSummary(fallback, t)
504503
}
505504
}
@@ -610,7 +609,7 @@ func startPeriodicSync(engine *sync.Engine) {
610609
defer ticker.Stop()
611610
for range ticker.C {
612611
log.Println("Running scheduled sync...")
613-
engine.SyncAll(nil)
612+
engine.SyncAll(context.Background(), nil)
614613
}
615614
}
616615

@@ -619,31 +618,6 @@ func startUnwatchedPoll(engine *sync.Engine) {
619618
defer ticker.Stop()
620619
for range ticker.C {
621620
log.Println("Polling unwatched directories...")
622-
engine.SyncAll(nil)
623-
}
624-
}
625-
626-
func openBrowser(url string) {
627-
for range browserPollAttempts {
628-
time.Sleep(browserPollInterval)
629-
resp, err := http.Get(url + "/api/v1/stats")
630-
if err == nil {
631-
resp.Body.Close()
632-
break
633-
}
634-
}
635-
636-
var cmd *exec.Cmd
637-
switch runtime.GOOS {
638-
case "darwin":
639-
cmd = exec.Command("open", url)
640-
case "linux":
641-
cmd = exec.Command("xdg-open", url)
642-
case "windows":
643-
cmd = exec.Command("rundll32",
644-
"url.dll,FileProtocolHandler", url)
645-
default:
646-
return
621+
engine.SyncAll(context.Background(), nil)
647622
}
648-
_ = cmd.Run()
649623
}

cmd/agentsview/main_test.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ func TestMustLoadConfig(t *testing.T) {
2020
wantPort int
2121
wantPublicURL string
2222
wantProxyMode string
23-
wantNoBrowser bool
2423
}{
2524
{
2625
name: "DefaultArgs",
@@ -29,7 +28,6 @@ func TestMustLoadConfig(t *testing.T) {
2928
wantPort: 8080,
3029
wantPublicURL: "",
3130
wantProxyMode: "",
32-
wantNoBrowser: false,
3331
},
3432
{
3533
name: "ExplicitFlags",
@@ -38,7 +36,6 @@ func TestMustLoadConfig(t *testing.T) {
3836
wantPort: 9090,
3937
wantPublicURL: "https://viewer.example.test:9443",
4038
wantProxyMode: "caddy",
41-
wantNoBrowser: true,
4239
},
4340
{
4441
name: "PartialFlags",
@@ -47,7 +44,6 @@ func TestMustLoadConfig(t *testing.T) {
4744
wantPort: 3000,
4845
wantPublicURL: "",
4946
wantProxyMode: "",
50-
wantNoBrowser: false,
5147
},
5248
}
5349

@@ -68,9 +64,6 @@ func TestMustLoadConfig(t *testing.T) {
6864
if cfg.Proxy.Mode != tt.wantProxyMode {
6965
t.Errorf("Proxy.Mode = %q, want %q", cfg.Proxy.Mode, tt.wantProxyMode)
7066
}
71-
if cfg.NoBrowser != tt.wantNoBrowser {
72-
t.Errorf("NoBrowser = %v, want %v", cfg.NoBrowser, tt.wantNoBrowser)
73-
}
7467

7568
if cfg.DataDir == "" {
7669
t.Error("DataDir should be set")

cmd/agentsview/sync.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,11 @@ func runSync(args []string) {
9797
Machine: "local",
9898
})
9999

100+
ctx := context.Background()
100101
if cfg.Full || database.NeedsResync() {
101-
runInitialResync(engine)
102+
runInitialResync(ctx, engine)
102103
} else {
103-
runInitialSync(engine)
104+
runInitialSync(ctx, engine)
104105
}
105106

106107
fmt.Println()

desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,7 @@ fn spawn_sidecar(app: &App) -> Result<(CommandRx, CommandChild), DynError> {
104104
}
105105

106106
Ok(command
107-
.args([
108-
"serve",
109-
"-no-browser",
110-
"-host",
111-
HOST,
112-
"-port",
113-
port_arg.as_str(),
114-
])
107+
.args(["serve", "-host", HOST, "-port", port_arg.as_str()])
115108
.spawn()?)
116109
}
117110

internal/config/config.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ type ProxyConfig struct {
5454
type Config struct {
5555
Host string `json:"host"`
5656
Port int `json:"port"`
57-
NoBrowser bool `json:"no_browser"`
5857
DataDir string `json:"data_dir"`
5958
DBPath string `json:"-"`
6059
PublicURL string `json:"public_url,omitempty"`
@@ -422,8 +421,6 @@ func applyFlags(cfg *Config, fs *flag.FlagSet) {
422421
cfg.Proxy.TLSKey = f.Value.String()
423422
case "allowed-subnet":
424423
cfg.Proxy.AllowedSubnets = splitFlagList(f.Value.String())
425-
case "no-browser":
426-
cfg.NoBrowser = f.Value.String() == "true"
427424
}
428425
})
429426
}

internal/server/events.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,12 @@ func (s *Server) handleTriggerSync(
218218
stream, err := NewSSEStream(w)
219219
if err != nil {
220220
// Non-streaming fallback
221-
stats := s.engine.SyncAll(nil)
221+
stats := s.engine.SyncAll(r.Context(), nil)
222222
writeJSON(w, http.StatusOK, stats)
223223
return
224224
}
225225

226-
stats := s.engine.SyncAll(func(p syncpkg.Progress) {
226+
stats := s.engine.SyncAll(r.Context(), func(p syncpkg.Progress) {
227227
stream.SendJSON("progress", p)
228228
})
229229
stream.SendJSON("done", stats)
@@ -234,12 +234,12 @@ func (s *Server) handleTriggerResync(
234234
) {
235235
stream, err := NewSSEStream(w)
236236
if err != nil {
237-
stats := s.engine.ResyncAll(nil)
237+
stats := s.engine.ResyncAll(r.Context(), nil)
238238
writeJSON(w, http.StatusOK, stats)
239239
return
240240
}
241241

242-
stats := s.engine.ResyncAll(func(p syncpkg.Progress) {
242+
stats := s.engine.ResyncAll(r.Context(), func(p syncpkg.Progress) {
243243
stream.SendJSON("progress", p)
244244
})
245245
stream.SendJSON("done", stats)

internal/server/server.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ type Server struct {
3838
version VersionInfo
3939
dataDir string
4040

41+
// baseCtx, when set, is used as the base context for all
42+
// incoming requests. Cancelling it causes SSE handlers to
43+
// exit promptly, which unblocks graceful shutdown.
44+
baseCtx context.Context
45+
4146
generateStreamFunc insight.GenerateStreamFunc
4247
spaFS fs.FS
4348
spaHandler http.Handler
@@ -92,6 +97,14 @@ func WithDataDir(dir string) Option {
9297
return func(s *Server) { s.dataDir = dir }
9398
}
9499

100+
// WithBaseContext sets the base context for all incoming HTTP
101+
// requests. When this context is cancelled, request contexts
102+
// are also cancelled, causing long-lived handlers (SSE) to
103+
// exit and unblocking graceful shutdown.
104+
func WithBaseContext(ctx context.Context) Option {
105+
return func(s *Server) { s.baseCtx = ctx }
106+
}
107+
95108
// WithUpdateChecker overrides the update check function,
96109
// allowing tests to substitute a deterministic stub.
97110
func WithUpdateChecker(f UpdateCheckFunc) Option {
@@ -521,6 +534,12 @@ func (s *Server) ListenAndServe() error {
521534
ReadTimeout: 10 * time.Second,
522535
IdleTimeout: 120 * time.Second,
523536
}
537+
if s.baseCtx != nil {
538+
ctx := s.baseCtx
539+
srv.BaseContext = func(_ net.Listener) context.Context {
540+
return ctx
541+
}
542+
}
524543
s.mu.Lock()
525544
s.httpSrv = srv
526545
s.mu.Unlock()

internal/server/server_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2253,7 +2253,7 @@ func TestWatchSession_Events(t *testing.T) {
22532253
},
22542254
Machine: "test",
22552255
})
2256-
engine.SyncAll(nil)
2256+
engine.SyncAll(context.Background(), nil)
22572257

22582258
ctx, cancel := context.WithTimeout(
22592259
context.Background(), 5*time.Second,
@@ -2306,7 +2306,7 @@ func TestWatchSession_FileDisappearAndResolve(t *testing.T) {
23062306
},
23072307
Machine: "test",
23082308
})
2309-
engine.SyncAll(nil)
2309+
engine.SyncAll(context.Background(), nil)
23102310

23112311
ctx, cancel := context.WithTimeout(
23122312
context.Background(), 15*time.Second,

internal/server/timeout_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func TestServerTimeouts(t *testing.T) {
2929
)
3030

3131
// Seed the DB.
32-
te.engine.SyncAll(nil)
32+
te.engine.SyncAll(context.Background(), nil)
3333

3434
baseURL := te.listenAndServe(t)
3535

0 commit comments

Comments
 (0)