diff --git a/main.go b/main.go index 85f23ae..6beeb9d 100644 --- a/main.go +++ b/main.go @@ -71,11 +71,8 @@ func main() { } slog.Debug("claude auth", "mechanism", auth.Name()) - sp := &spoolSync{} - defer sp.close() - if *serveAddr != "" { - if err := serve(*serveAddr, auth, sp, *dryRun); err != nil { + if err := serve(*serveAddr, auth, *dryRun); err != nil { fail("serve: %v", err) } return @@ -87,7 +84,7 @@ func main() { } results := make([]result, 0, flag.NArg()) for _, path := range flag.Args() { - results = append(results, processImage(path, auth, sp, *dryRun)) + results = append(results, processImage(path, auth, *dryRun)) } enc := json.NewEncoder(os.Stdout) @@ -95,50 +92,43 @@ func main() { enc.Encode(results) } -// spoolSync lazily logs into spooldb on first use, so its (slow) browser login -// overlaps with the first image's weight read rather than blocking it. Every -// site interaction is best-effort: failures are logged and the corresponding -// output fields are simply omitted. -type spoolSync struct { - once sync.Once +// spoolSession is a fresh, logged-in spooldb session used for a single image: +// it owns one headless browser, does the read and the maybe-write, then closes. +// Every site interaction is best-effort — failures are logged and the +// corresponding output fields are simply omitted. +type spoolSession struct { client *spooldb.Client - err error } -func (s *spoolSync) login() (*spooldb.Client, error) { - s.once.Do(func() { - user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS") - if user == "" || pass == "" { - s.err = fmt.Errorf("SPOOLDB_USER/SPOOLDB_PASS not set") - return - } - c, err := spooldb.New() - if err != nil { - s.err = err - return - } - ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) - defer cancel() - if err := c.Login(ctx, user, pass); err != nil { - c.Close() - s.err = fmt.Errorf("login: %w", err) - return - } - s.client = c - }) - return s.client, s.err +// openSpoolSession launches a browser and logs into spooldb. It returns nil (and +// logs) if credentials are missing or login fails, so the caller can carry on +// with just the weight read. +func openSpoolSession() *spoolSession { + user, pass := os.Getenv("SPOOLDB_USER"), os.Getenv("SPOOLDB_PASS") + if user == "" || pass == "" { + slog.Warn("spooldb: skipping (SPOOLDB_USER/SPOOLDB_PASS not set)") + return nil + } + c, err := spooldb.New() + if err != nil { + slog.Warn("spooldb: skipping (browser launch failed)", "err", err) + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if err := c.Login(ctx, user, pass); err != nil { + c.Close() + slog.Warn("spooldb: skipping (login failed)", "err", err) + return nil + } + return &spoolSession{client: c} } // info reads the spool's current location and weights, or nil if unavailable. -func (s *spoolSync) info(spoolID string) *spooldb.SpoolInfo { - client, err := s.login() - if err != nil { - slog.Warn("spooldb: skipping spool lookup", "err", err) - return nil - } - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) +func (s *spoolSession) info(spoolID string) *spooldb.SpoolInfo { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - info, err := client.SpoolInfo(ctx, spoolID) + info, err := s.client.SpoolInfo(ctx, spoolID) if err != nil { slog.Warn("spooldb: lookup failed", "spool", spoolID, "err", err) return nil @@ -147,22 +137,17 @@ func (s *spoolSync) info(spoolID string) *spooldb.SpoolInfo { } // setTotal writes a new total (with-spool) weight back to the site. -func (s *spoolSync) setTotal(spoolID string, grams float64) bool { - client, err := s.login() - if err != nil { - slog.Warn("spooldb: skipping weight update", "err", err) - return false - } +func (s *spoolSession) setTotal(spoolID string, grams float64) bool { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - if err := client.SetTotalWeight(ctx, spoolID, grams); err != nil { + if err := s.client.SetTotalWeight(ctx, spoolID, grams); err != nil { slog.Warn("spooldb: weight update failed", "spool", spoolID, "err", err) return false } return true } -func (s *spoolSync) close() { +func (s *spoolSession) close() { if s.client != nil { s.client.Close() } @@ -170,17 +155,17 @@ func (s *spoolSync) close() { // processImage reads one photo, capturing any failure in the result's Error // field so a single bad image doesn't abort the whole batch. -func processImage(path string, auth claude.Auth, sp *spoolSync, dryRun bool) result { +func processImage(path string, auth claude.Auth, dryRun bool) result { img, err := loadImage(path) if err != nil { return result{Image: path, Error: fmt.Sprintf("load image: %v", err)} } - return processImg(img, path, auth, sp, dryRun) + return processImg(img, path, auth, dryRun) } // processImg runs the QR + weight + spooldb logic on a decoded image. name is // just a label for the output (a file path or upload filename). -func processImg(img image.Image, name string, auth claude.Auth, sp *spoolSync, dryRun bool) result { +func processImg(img image.Image, name string, auth claude.Auth, dryRun bool) result { defer trace("image " + name)() r := result{Image: name} @@ -196,18 +181,25 @@ func processImg(img image.Image, name string, auth claude.Auth, sp *spoolSync, d r.SpoolID = spoolID(url) r.URL = url - // Read the spool's current location/weight from the site concurrently with - // the (slower) weight read off the photo. + // Open a fresh spooldb session and read the spool's current location/weight, + // concurrently with the (slower) weight read off the photo. The same session + // is reused for the maybe-write below, then closed. + var sess *spoolSession var info *spooldb.SpoolInfo var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() - info = sp.info(r.SpoolID) + if sess = openSpoolSession(); sess != nil { + info = sess.info(r.SpoolID) + } }() reading, err := claude.ReadWeight(img, auth) wg.Wait() + if sess != nil { + defer sess.close() + } if info != nil { r.Location = info.Location r.PreviousWeight = &weightBreakdown{ @@ -246,7 +238,7 @@ func processImg(img image.Image, name string, auth claude.Auth, sp *spoolSync, d if dryRun { slog.Info("spooldb: would update", "spool", r.SpoolID, "remaining_from", info.RemainingGrams, "remaining_to", newRemaining, "measured_total", reading.Weight) - } else if sp.setTotal(r.SpoolID, reading.Weight) { + } else if sess.setTotal(r.SpoolID, reading.Weight) { updated = true slog.Info("spooldb: updated", "spool", r.SpoolID, "remaining_from", info.RemainingGrams, "remaining_to", newRemaining, "measured_total", reading.Weight) diff --git a/server.go b/server.go index f110057..70d1e24 100644 --- a/server.go +++ b/server.go @@ -15,9 +15,10 @@ import ( //go:embed index.html var indexHTML []byte -// serve runs the mobile putaway web server. Requests are serialized because the -// shared spooldb browser session is single-threaded. -func serve(addr string, auth claude.Auth, sp *spoolSync, dryRun bool) error { +// serve runs the mobile putaway web server. Each request gets its own fresh +// spooldb session; requests are serialized so we never run several headless +// browsers at once. +func serve(addr string, auth claude.Auth, dryRun bool) error { var mu sync.Mutex mux := http.NewServeMux() @@ -53,7 +54,7 @@ func serve(addr string, auth claude.Auth, sp *spoolSync, dryRun bool) error { start := time.Now() mu.Lock() - res := processImg(img, hdr.Filename, auth, sp, dryRun) + res := processImg(img, hdr.Filename, auth, dryRun) mu.Unlock() slog.Info("processed upload", "file", hdr.Filename, "spool", res.SpoolID, "dur", time.Since(start))