// Package instance makes ipswap single-instance and gives a second launch a // way to talk to the first. // // The behaviour it implements: double-clicking the shortcut should open the // editor in a browser. If ipswap is not running yet, that means starting the // tray and opening the browser. If it is already running, it means asking the // running copy to open the browser — not starting a second tray icon, and not // silently doing nothing. // // The mechanism is a small loopback control listener owned by the primary // instance, whose port and token are published in a session file. A second // launch reads that file, calls the listener and exits. The session file is // authoritative about *how to talk to* the primary; on Windows a named mutex // additionally settles *who is* the primary, closing the race between two // launches starting at the same moment. package instance import ( "encoding/json" "errors" "fmt" "io" "log" "net" "net/http" "os" "path/filepath" "strings" "sync" "time" "gitea.apointless.space/bsncubed/ipswap/internal/config" ) // dialTimeout bounds every call to a possibly-dead primary. A stale session // file must not add a visible delay to startup. const dialTimeout = 1500 * time.Millisecond // ErrNoPrimary means no other instance is running. var ErrNoPrimary = errors.New("no running ipswap instance") // session is the contents of session.json. type session struct { Port int `json:"port"` Token string `json:"token"` PID int `json:"pid"` } func sessionPath(paths config.Paths) string { return filepath.Join(paths.Dir, "session.json") } // Primary is the control listener owned by the running instance. type Primary struct { path string token string mu sync.Mutex ln net.Listener srv *http.Server lock releaser } // releaser is whatever the platform uses to claim single-instance ownership. type releaser interface{ release() } // Acquire makes this process the primary instance and starts its control // listener. onOpenEditor is called when another launch asks for the editor. // // It returns ErrAlreadyRunning if another instance already holds the slot; the // caller should then call Signal and exit. func Acquire(paths config.Paths, onOpenEditor func()) (*Primary, error) { // Take the OS-level lock first: it is the only thing that makes two // simultaneous launches deterministic. lock, err := lockProcess() if err != nil { return nil, err } if err := os.MkdirAll(paths.Dir, 0o755); err != nil { lock.release() return nil, fmt.Errorf("creating the data directory: %w", err) } token, err := newToken() if err != nil { lock.release() return nil, err } ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { lock.release() return nil, fmt.Errorf("binding the control port: %w", err) } p := &Primary{path: sessionPath(paths), token: token, ln: ln, lock: lock} mux := http.NewServeMux() mux.HandleFunc("POST /ping", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) mux.HandleFunc("POST /open", func(w http.ResponseWriter, r *http.Request) { // Answer first, then act: the caller is a process waiting to exit, and // opening a browser can take a moment. w.WriteHeader(http.StatusOK) if onOpenEditor != nil { go onOpenEditor() } }) p.srv = &http.Server{ Handler: p.withToken(mux), ReadHeaderTimeout: 5 * time.Second, } if err := p.writeSession(ln.Addr().(*net.TCPAddr).Port, token); err != nil { ln.Close() lock.release() return nil, err } // Capture the handles before serving: Release nils the fields under the // mutex, and the serving goroutine must not read them concurrently. srv := p.srv go func() { if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { log.Printf("control listener stopped: %v", err) } }() log.Printf("control listener on 127.0.0.1:%d", ln.Addr().(*net.TCPAddr).Port) return p, nil } func (p *Primary) withToken(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Loopback plus a per-run token: enough to stop a stray page in the // browser poking the control channel, which is all it needs to resist. if r.Header.Get("X-Ipswap-Control") != p.token { http.Error(w, "unauthorised", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } // writeSession publishes the control address atomically, so a second launch // can never read a half-written file. func (p *Primary) writeSession(port int, token string) error { b, err := json.MarshalIndent(session{Port: port, Token: token, PID: os.Getpid()}, "", " ") if err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(p.path), ".session-*.json") if err != nil { return fmt.Errorf("creating the session file: %w", err) } tmpName := tmp.Name() defer os.Remove(tmpName) // 0600: the token in here is what authorises control calls. if err := tmp.Chmod(0o600); err != nil { tmp.Close() return err } if _, err := tmp.Write(b); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmpName, p.path) } // Release shuts the control listener down and removes the session file. func (p *Primary) Release() { p.mu.Lock() srv, lock := p.srv, p.lock p.srv, p.ln, p.lock = nil, nil, nil p.mu.Unlock() if srv != nil { _ = srv.Close() } // Remove the session file before dropping the lock, so a launch that gets // the lock next never sees this instance's stale address. _ = os.Remove(p.path) if lock != nil { lock.release() } } // Signal asks an already-running instance to open the editor. It returns // ErrNoPrimary when nothing is listening, which is the caller's cue to start // up normally. func Signal(paths config.Paths) error { s, err := readSession(sessionPath(paths)) if err != nil { return err } return call(s, "/open") } // IsRunning reports whether another instance answers on the control channel. func IsRunning(paths config.Paths) bool { s, err := readSession(sessionPath(paths)) if err != nil { return false } return call(s, "/ping") == nil } func readSession(path string) (session, error) { b, err := os.ReadFile(path) if err != nil { return session{}, ErrNoPrimary } var s session if err := json.Unmarshal(b, &s); err != nil || s.Port == 0 || s.Token == "" { return session{}, ErrNoPrimary } return s, nil } func call(s session, path string) error { url := fmt.Sprintf("http://127.0.0.1:%d%s", s.Port, path) req, err := http.NewRequest(http.MethodPost, url, nil) if err != nil { return err } req.Header.Set("X-Ipswap-Control", s.Token) client := &http.Client{Timeout: dialTimeout} resp, err := client.Do(req) if err != nil { // A session file left behind by a crash points at a port nothing is // listening on, or one since reused by an unrelated process. return ErrNoPrimary } defer resp.Body.Close() io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) if resp.StatusCode != http.StatusOK { return ErrNoPrimary } return nil } func newToken() (string, error) { var b [32]byte if _, err := readRandom(b[:]); err != nil { return "", fmt.Errorf("generating a control token: %w", err) } const hex = "0123456789abcdef" var sb strings.Builder for _, c := range b { sb.WriteByte(hex[c>>4]) sb.WriteByte(hex[c&0x0f]) } return sb.String(), nil }