Compare commits

..
4 Commits
Author SHA1 Message Date
mram 7a6409831c Pin compose example to v0.2.7
ci / test (push) Successful in 14s
ci / docker (push) Successful in 1m7s
ci / release (push) Successful in 16s
2026-09-22 08:47:47 +02:00
mram c7b8747d85 Fix control pipe accept loop: create instances only after a client connects; CLI force-update never touches the service log file 2026-09-22 08:43:58 +02:00
mram 09d4e81eab Pin compose example to v0.2.6
ci / test (push) Successful in 15s
ci / docker (push) Successful in 1m6s
ci / release (push) Successful in 15s
2026-09-22 08:36:36 +02:00
mram 1264afe40e Monitor restarts itself when the service updated its binary on disk 2026-09-22 08:34:47 +02:00
4 changed files with 58 additions and 14 deletions
+7
View File
@@ -362,6 +362,10 @@ func forceUpdateCommand(configPath string, elevatedChild bool) int {
fmt.Fprintf(os.Stderr, "gpu-turnstile: cannot locate executable: %v\n", err) fmt.Fprintf(os.Stderr, "gpu-turnstile: cannot locate executable: %v\n", err)
return 1 return 1
} }
// One-shot CLI: the updater logs to stderr, never to the service's
// LOG_FILE — that file is ACL'd to the service account, and a CLI run
// has nothing worth persisting there.
cfg.LogFile = ""
log, _, logCloser := newLogger(cfg) log, _, logCloser := newLogger(cfg)
defer logCloser.Close() defer logCloser.Close()
if cfg.AppVersion == "dev" { if cfg.AppVersion == "dev" {
@@ -1034,6 +1038,9 @@ type statusSnapshot struct {
UptimeS int64 `json:"uptime_s"` UptimeS int64 `json:"uptime_s"`
Downstreams []statusDownstream `json:"downstreams"` Downstreams []statusDownstream `json:"downstreams"`
Lock statusLock `json:"lock"` Lock statusLock `json:"lock"`
// MonitorNote is set client-side (never over the wire) when the
// monitor's own binary differs from the service's version.
MonitorNote string `json:"-"`
} }
// statusProvider assembles the one-line JSON snapshot for CmdStatus. // statusProvider assembles the one-line JSON snapshot for CmdStatus.
+40 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"strings" "strings"
"time" "time"
@@ -21,7 +22,10 @@ const (
) )
// monitorCommand renders a live status view of the running service, // monitorCommand renders a live status view of the running service,
// refreshed every second from the control channel. Ctrl+C quits. // refreshed every second from the control channel. When the service
// reports a different version and the executable on disk changed (the
// updater replaced it), the monitor restarts itself onto the new binary.
// Ctrl+C quits.
func monitorCommand() int { func monitorCommand() int {
if !stdoutIsTerminal() { if !stdoutIsTerminal() {
fmt.Fprintln(os.Stderr, "gpu-turnstile: --monitor needs an interactive terminal") fmt.Fprintln(os.Stderr, "gpu-turnstile: --monitor needs an interactive terminal")
@@ -30,12 +34,26 @@ func monitorCommand() int {
enableVirtualTerminal() enableVirtualTerminal()
fmt.Print("\x1b[2J") // clear once; frames then redraw in place fmt.Print("\x1b[2J") // clear once; frames then redraw in place
defer fmt.Print(cReset + "\n") defer fmt.Print(cReset + "\n")
exe, _ := os.Executable()
var exeStamp time.Time
if st, err := os.Stat(exe); err == nil {
exeStamp = st.ModTime()
}
for { for {
frame := renderWaiting() frame := renderWaiting()
if reply, err := control.Ask(control.CmdStatus); err == nil { if reply, err := control.Ask(control.CmdStatus); err == nil {
if msg, ok := strings.CutPrefix(reply, "OK "); ok { if msg, ok := strings.CutPrefix(reply, "OK "); ok {
var snap statusSnapshot var snap statusSnapshot
if json.Unmarshal([]byte(msg), &snap) == nil { if json.Unmarshal([]byte(msg), &snap) == nil {
if snap.Version != "" && snap.Version != version {
if exeChanged(exe, exeStamp) {
fmt.Print("\x1b[2J\x1b[H")
fmt.Printf("gpu-turnstile: service updated to %s — restarting the monitor\n", snap.Version)
restartSelf(exe, "--monitor")
return 0
}
snap.MonitorNote = fmt.Sprintf("note: the service runs %s, this monitor is %s", snap.Version, version)
}
frame = renderMonitor(snap, termWidth()) frame = renderMonitor(snap, termWidth())
} }
} }
@@ -45,6 +63,24 @@ func monitorCommand() int {
} }
} }
// exeChanged reports whether the executable on disk was replaced since the
// recorded stamp (the updater swaps it via rename, which changes ModTime).
func exeChanged(exe string, stamp time.Time) bool {
if exe == "" || stamp.IsZero() {
return false
}
st, err := os.Stat(exe)
return err == nil && !st.ModTime().Equal(stamp)
}
// restartSelf starts a fresh copy of this executable with the given args on
// the same console; the caller exits right after.
func restartSelf(exe string, args ...string) {
cmd := exec.Command(exe, args...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
cmd.Start() //nolint:errcheck // best effort: on failure we just exit
}
func renderWaiting() string { func renderWaiting() string {
return cDim + " gpu-turnstile — waiting for a running service…" + cReset + "\x1b[K\n" return cDim + " gpu-turnstile — waiting for a running service…" + cReset + "\x1b[K\n"
} }
@@ -81,6 +117,9 @@ func renderMonitor(snap statusSnapshot, width int) string {
b.WriteString(fmt.Sprintf(" Queue: %s%d image job(s) waiting%s\x1b[K\n", b.WriteString(fmt.Sprintf(" Queue: %s%d image job(s) waiting%s\x1b[K\n",
cYellow, snap.Lock.ImageQueue, cReset)) cYellow, snap.Lock.ImageQueue, cReset))
} }
if snap.MonitorNote != "" {
b.WriteString(" " + cYellow + snap.MonitorNote + cReset + "\x1b[K\n")
}
return b.String() return b.String()
} }
+1 -1
View File
@@ -6,7 +6,7 @@
# ComfyUI --listen 0.0.0.0 --port 8189). # ComfyUI --listen 0.0.0.0 --port 8189).
services: services:
gpu-turnstile: gpu-turnstile:
image: git.rambossek.at/public/gpu-turnstile:v0.2.5 image: git.rambossek.at/public/gpu-turnstile:v0.2.7
restart: unless-stopped restart: unless-stopped
environment: environment:
# Each consumer is enabled by setting its URL; leave one unset to # Each consumer is enabled by setting its URL; leave one unset to
+10 -12
View File
@@ -77,18 +77,16 @@ func Serve(ctx context.Context, h Handler, log *slog.Logger) error {
log.Warn("control channel stopped", "err", err) log.Warn("control channel stopped", "err", err)
return return
} }
go func() { // Blocks until a client connects — only then is the next
// Blocks until a client connects; on process exit the // instance created, so instances are not burned without
// handle goes away with everything else. A client that // clients. ERROR_PIPE_CONNECTED means the client raced us
// raced us and connected between CreateNamedPipe and // and connected before the call: that is a success. Process
// ConnectNamedPipe reports ERROR_PIPE_CONNECTED — that is // exit reaps the blocked call on shutdown.
// a success, not a failure. if err := windows.ConnectNamedPipe(pipe, nil); err != nil && err != errnoPipeConnected {
if err := windows.ConnectNamedPipe(pipe, nil); err != nil && err != errnoPipeConnected { windows.CloseHandle(pipe)
windows.CloseHandle(pipe) continue
return }
} go serveConn(&pipeConn{f: os.NewFile(uintptr(pipe), pipePath), h: pipe}, h)
serveConn(&pipeConn{f: os.NewFile(uintptr(pipe), pipePath), h: pipe}, h)
}()
} }
}() }()
return nil return nil