Files
geniusrun/backend/internal/garmin/client.go
Christophe Vila df197f45fd fix(garmin): wait for stderr-copy goroutine before cmd.Wait()
ensureStarted's stderr-copy goroutine could still be mid-Read when close()
called cmd.Wait(), which os/exec's StderrPipe docs call out as incorrect
and can truncate/garble trailing stderr diagnostics or surface a spurious
"file already closed" error. close() now waits on a stderrDone channel,
closed by the copy goroutine once it hits EOF (unblocked by killing the
process), before calling Wait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:50:39 +02:00

278 lines
8.3 KiB
Go

// Package garmin wraps a direct garminconnect subprocess (see
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
// geniusrun never deals with the wire protocol directly.
package garmin
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"sync"
)
//go:embed pyscript/wrapper.py
var wrapperScript string
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
// subprocess -- well above the default 64KB bufio.Scanner limit, since
// get_activity_details responses (per-second telemetry) can be several MB.
const maxWrapperLineBytes = 16 * 1024 * 1024
// Client is the interface the rest of geniusrun depends on. The real
// implementation drives an embedded Python wrapper subprocess over stdio;
// internal/garmin/mock provides a fake for tests and frontend-only
// development.
type Client interface {
// Authenticate triggers Garmin login using credentials the subprocess
// was started with. Spawns the subprocess on first call.
Authenticate(ctx context.Context) (AuthResult, error)
// CompleteMFA submits an MFA code for a login started by Authenticate.
CompleteMFA(ctx context.Context, code string) (AuthResult, error)
// UpdateCredentials replaces the Garmin email/password used to spawn
// the subprocess, and terminates any already-running subprocess (which
// would otherwise still be authenticated under the old credentials).
// The next call that needs the subprocess spawns a fresh one with the
// new credentials.
UpdateCredentials(email, password string)
// GetActivities lists activities between start and end (YYYY-MM-DD).
GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error)
// GetActivitySplits fetches lap/split summaries for one activity.
GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error)
// GetActivityDetails fetches raw per-second telemetry for one activity.
GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error)
// GetWorkoutByID fetches a structured workout's step-by-step plan.
GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error)
// Close terminates the subprocess, if running.
Close() error
}
// Config configures how the Garmin wrapper subprocess is spawned.
type Config struct {
// PythonPath is the python3 interpreter to run the embedded wrapper
// script with. Empty defaults to "python3" resolved via PATH.
PythonPath string
GarminEmail string
GarminPassword string
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so the wrapper
// persists/resumes a Garmin session there instead of the default ~/.garth.
TokenStorePath string
}
// wireRequest is one line sent to the wrapper subprocess's stdin.
type wireRequest struct {
ID int `json:"id"`
Cmd string `json:"cmd"`
Params any `json:"params,omitempty"`
}
// wireResponse is one line read from the wrapper subprocess's stdout.
type wireResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// callParams is the Params payload for a generic "call" request: dispatches
// to any garminconnect.Garmin method by name.
type callParams struct {
Method string `json:"method"`
Args map[string]any `json:"args,omitempty"`
}
// authResultWire is the Result payload for authenticate/complete_mfa.
type authResultWire struct {
Status string `json:"status"`
Message string `json:"message"`
}
func mapAuthStatus(s string) AuthStatus {
switch s {
case "success":
return AuthSuccess
case "mfa_required":
return AuthMFARequired
case "failed":
return AuthFailed
default:
return AuthUnknown
}
}
// subprocessClient is the real Client implementation, backed by a wrapper
// subprocess spoken to over newline-delimited JSON on stdio.
type subprocessClient struct {
cfg Config
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
cmd *exec.Cmd
stdin io.WriteCloser
enc *json.Encoder
scanner *bufio.Scanner
scriptPath string // temp file holding the embedded wrapper.py, written once
started bool
nextID int
stderrDone chan struct{} // closed once the stderr-copy goroutine has finished reading
}
// ensureStarted spawns the wrapper subprocess if it isn't already running.
// Callers must hold c.mu.
func (c *subprocessClient) ensureStarted() error {
if c.started {
return nil
}
if c.scriptPath == "" {
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
if err != nil {
return fmt.Errorf("write embedded wrapper script: %w", err)
}
if _, err := f.WriteString(wrapperScript); err != nil {
f.Close()
return fmt.Errorf("write embedded wrapper script: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("write embedded wrapper script: %w", err)
}
c.scriptPath = f.Name()
}
pythonPath := c.cfg.PythonPath
if pythonPath == "" {
pythonPath = "python3"
}
cmd := exec.Command(pythonPath, c.scriptPath)
extraEnv := []string{
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
"PYTHONUNBUFFERED=1",
}
if c.cfg.TokenStorePath != "" {
extraEnv = append(extraEnv, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
}
cmd.Env = append(os.Environ(), extraEnv...)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("open wrapper stdin: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("open wrapper stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("open wrapper stderr: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("spawn garmin wrapper subprocess: %w", err)
}
// wrapper.py logs auth/rate-limit diagnostics to stderr. Per os/exec's
// StderrPipe docs, it's incorrect to call Wait before all reads from the
// pipe have completed, so close() waits on stderrDone before Wait-ing.
c.stderrDone = make(chan struct{})
stderrDone := c.stderrDone
go func() {
io.Copy(os.Stderr, stderr)
close(stderrDone)
}()
c.cmd = cmd
c.stdin = stdin
c.enc = json.NewEncoder(stdin)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c.scanner = scanner
c.started = true
c.nextID = 0
return nil
}
// roundTrip sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted.
func (c *subprocessClient) roundTrip(cmdName string, params any) (json.RawMessage, error) {
c.nextID++
id := c.nextID
if err := c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
return nil, fmt.Errorf("write %s request: %w", cmdName, err)
}
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
return nil, fmt.Errorf("read %s response: %w", cmdName, err)
}
return nil, fmt.Errorf("read %s response: subprocess closed its output", cmdName)
}
var resp wireResponse
if err := json.Unmarshal(c.scanner.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("parse %s response: %w", cmdName, err)
}
if resp.ID != id {
return nil, fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
}
if resp.Error != "" {
return nil, fmt.Errorf("%s: %s", cmdName, resp.Error)
}
return resp.Result, nil
}
// UpdateCredentials implements Client.
func (c *subprocessClient) UpdateCredentials(email, password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.GarminEmail = email
c.cfg.GarminPassword = password
c.close()
}
// Close implements Client.
func (c *subprocessClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.close()
}
// close terminates the subprocess, if running. Callers must hold c.mu.
func (c *subprocessClient) close() error {
if !c.started {
return nil
}
c.started = false
if c.stdin != nil {
c.stdin.Close()
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
if c.stderrDone != nil {
// Killing the process closes its end of the stderr pipe, which
// unblocks the copy goroutine's Read with EOF; wait for it to finish
// before Wait, per os/exec's StderrPipe doc.
<-c.stderrDone
}
var err error
if c.cmd != nil {
err = c.cmd.Wait()
}
c.cmd, c.stdin, c.enc, c.scanner, c.stderrDone = nil, nil, nil, nil, nil
return err
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}