diff --git a/share b/share index b74453b..31362d5 100755 Binary files a/share and b/share differ diff --git a/src/cli/cli.go b/src/cli/cli.go index 1ab43fe..f602674 100644 --- a/src/cli/cli.go +++ b/src/cli/cli.go @@ -35,22 +35,34 @@ USAGE EXAMPLES: Join a room to receive whatever's shared there (and help re-share it): share secret-code - See what rooms are currently active: + See what rooms are currently active (or paused): share status Stop the background daemon (leaves every room it's in): share stop + Pause just one room, without affecting any other room or the daemon — + it stays in "share status" and can be resumed later: + share stop secret-code + + Forget one room entirely (it stops showing up in "share status" and + won't come back on a restart): + share leave secret-code + Restart the background daemon (e.g. after upgrading the binary) — rooms it was in resume automatically: share restart + Restart (or resume a paused) room on its own: + share restart secret-code + COMMANDS: send seed file(s) into a room (see options with: share send -h) relay start your own relay (optional; see options with: share relay -h) - status show the rooms the background daemon is currently in - stop stop the background daemon and leave every room - restart stop and relaunch the background daemon + status show the rooms the background daemon knows about + stop stop the daemon, or pause just one room if given a room code + leave forget one specific room for good + restart restart the daemon, or just one room if given a room code daemon run the background daemon in the foreground (mostly for debugging) Run "share [command] -h" for the options of a given command. @@ -87,6 +99,8 @@ func Run() error { return runRelay(splice(args, i)) case "stop": return runStop(splice(args, i)) + case "leave": + return runLeave(splice(args, i)) case "restart": return runRestart(splice(args, i)) case "status": @@ -244,12 +258,21 @@ func runRelay(args []string) error { func runStop(args []string) error { fs := flag.NewFlagSet("stop", flag.ExitOnError) fs.Usage = func() { - fmt.Fprintln(os.Stderr, "share stop") + fmt.Fprintln(os.Stderr, "share stop [room-code]") } if err := fs.Parse(args); err != nil { return err } + if code := fs.Arg(0); code != "" { + if err := daemon.StopRoom(code); err != nil { + fmt.Println(err) + return nil + } + fmt.Printf("Stopped room %s; it's still remembered — \"share status\" will show it, and \"share restart %s\" resumes it.\n", code, code) + return nil + } + if err := daemon.Stop(); err != nil { fmt.Println(err) return nil @@ -258,10 +281,33 @@ func runStop(args []string) error { return nil } +func runLeave(args []string) error { + fs := flag.NewFlagSet("leave", flag.ExitOnError) + fs.Usage = func() { + fmt.Fprintln(os.Stderr, "share leave room-code") + } + if err := fs.Parse(args); err != nil { + return err + } + + code := fs.Arg(0) + if code == "" { + fs.Usage() + return nil + } + + if err := daemon.Leave(code); err != nil { + fmt.Println(err) + return nil + } + fmt.Printf("Left room %s.\n", code) + return nil +} + func runRestart(args []string) error { fs := flag.NewFlagSet("restart", flag.ExitOnError) fs.Usage = func() { - fmt.Fprintln(os.Stderr, "share restart [options]") + fmt.Fprintln(os.Stderr, "share restart [options] [room-code]") fs.PrintDefaults() } debug := fs.Bool("debug", false, "toggle debug mode for the relaunched daemon") @@ -269,6 +315,14 @@ func runRestart(args []string) error { return err } + if code := fs.Arg(0); code != "" { + if err := daemon.RestartRoom(code); err != nil { + return err + } + fmt.Printf("Restarting room %s.\n", code) + return nil + } + if daemon.IsRunning() { if err := daemon.Stop(); err != nil { return fmt.Errorf("could not stop the running daemon: %w", err) @@ -300,16 +354,25 @@ func runStatus(args []string) error { return nil } if len(rooms) == 0 { - fmt.Println("No active rooms.") + fmt.Println("No rooms.") return nil } for _, r := range rooms { - peerWord := "peers" - if r.Peers == 1 { - peerWord = "peer" + state := "running" + detail := fmt.Sprintf(", %d peer", r.Peers) + if r.Peers != 1 { + detail += "s" + } + if r.Stopped { + state = "stopped" + detail = "" + } + fmt.Printf("Room %s (%s%s)\n", r.Code, state, detail) + fmt.Printf(" relay: %s\n", r.RelayAddress) + if r.Stopped { + fmt.Printf(" run \"share restart %s\" to resume\n", r.Code) } - fmt.Printf("Room %s (%d %s)\n", r.Code, r.Peers, peerWord) for _, f := range r.Files { state := "in progress" if f.Complete { diff --git a/src/daemon/client.go b/src/daemon/client.go index 84df207..23d34e2 100644 --- a/src/daemon/client.go +++ b/src/daemon/client.go @@ -95,6 +95,40 @@ func Stop() error { return err } +// StopRoom pauses one specific room without affecting any other room or +// the daemon itself. Unlike Leave, the room stays remembered — it still +// shows up in Status and resumes on the next daemon restart, and can be +// restarted on its own with RestartRoom. +func StopRoom(code string) error { + if !IsRunning() { + return fmt.Errorf("daemon is not running") + } + _, err := call(request{Action: "stop-room", Code: code}) + return err +} + +// Leave tells the running daemon to stop participating in one specific +// room, without affecting any other room or the daemon itself. Unlike +// StopRoom, the room is forgotten — it will not resume on the next +// restart and stops showing up in Status. +func Leave(code string) error { + if !IsRunning() { + return fmt.Errorf("daemon is not running") + } + _, err := call(request{Action: "leave", Code: code}) + return err +} + +// RestartRoom tells the running daemon to stop and rejoin one specific +// room with its original settings, without affecting any other room. +func RestartRoom(code string) error { + if !IsRunning() { + return fmt.Errorf("daemon is not running") + } + _, err := call(request{Action: "restart-room", Code: code}) + return err +} + // WaitUntilStopped polls until the daemon is no longer reachable, or // returns an error once timeout elapses. Useful right after Stop, since // its response is sent before the daemon has necessarily finished tearing diff --git a/src/daemon/protocol.go b/src/daemon/protocol.go index 894941f..b5080cb 100644 --- a/src/daemon/protocol.go +++ b/src/daemon/protocol.go @@ -5,8 +5,9 @@ import "github.com/tzgyn/share/src/swarm" // request/response are the only vocabulary spoken over the control // socket between a CLI invocation and the background daemon. type request struct { - Action string `json:"action"` // "join", "stop", or "status" + Action string `json:"action"` // "join", "stop-room", "leave", "restart-room", "stop", or "status" Join *joinRequest `json:"join,omitempty"` + Code string `json:"code,omitempty"` // "stop-room"/"leave"/"restart-room" only } // joinRequest asks the daemon to start participating in a room. Paths diff --git a/src/daemon/server.go b/src/daemon/server.go index 536e167..1c26616 100644 --- a/src/daemon/server.go +++ b/src/daemon/server.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "os" + "path/filepath" "sync" "time" @@ -12,9 +13,21 @@ import ( "github.com/tzgyn/share/src/swarm" ) +// activeRoom pairs a running room with the request that started it, so a +// per-room restart can rejoin with identical settings. +type activeRoom struct { + client *swarm.Client + req joinRequest +} + type server struct { mu sync.Mutex - rooms map[string]*swarm.Client + rooms map[string]*activeRoom + // leaving/restarting record intent for the *next* time a room's Wait + // resolves, set right before calling Stop so the room's own + // completion goroutine (started in join) knows what to do next. + leaving map[string]bool + restarting map[string]bool } // Serve runs the daemon: it listens on the control socket and services @@ -41,7 +54,11 @@ func Serve() error { } defer os.Remove(path) - s := &server{rooms: make(map[string]*swarm.Client)} + s := &server{ + rooms: make(map[string]*activeRoom), + leaving: make(map[string]bool), + restarting: make(map[string]bool), + } log.Infof("daemon listening on %s", path) // Resuming persisted rooms happens in the background, not before the @@ -108,6 +125,39 @@ func (s *server) handleConn(conn net.Conn) (shutdown bool) { } respond(conn, response{OK: true}) return false + case "stop-room": + if req.Code == "" { + respond(conn, response{OK: false, Error: "missing room code"}) + return false + } + if err := s.stopRoom(req.Code); err != nil { + respond(conn, response{OK: false, Error: err.Error()}) + return false + } + respond(conn, response{OK: true}) + return false + case "leave": + if req.Code == "" { + respond(conn, response{OK: false, Error: "missing room code"}) + return false + } + if err := s.leave(req.Code); err != nil { + respond(conn, response{OK: false, Error: err.Error()}) + return false + } + respond(conn, response{OK: true}) + return false + case "restart-room": + if req.Code == "" { + respond(conn, response{OK: false, Error: "missing room code"}) + return false + } + if err := s.restartRoom(req.Code); err != nil { + respond(conn, response{OK: false, Error: err.Error()}) + return false + } + respond(conn, response{OK: true}) + return false case "stop": respond(conn, response{OK: true}) return true @@ -146,7 +196,7 @@ func (s *server) join(req joinRequest) error { } s.mu.Lock() - s.rooms[req.Code] = cl + s.rooms[req.Code] = &activeRoom{client: cl, req: req} s.mu.Unlock() log.Infof("joined room %s", req.Code) @@ -156,22 +206,39 @@ func (s *server) join(req joinRequest) error { go func() { err := cl.Wait() + s.mu.Lock() delete(s.rooms, req.Code) + leftDeliberately := s.leaving[req.Code] + restart := s.restarting[req.Code] + delete(s.leaving, req.Code) + delete(s.restarting, req.Code) s.mu.Unlock() - if err != nil { + + switch { + case restart: + log.Infof("restarting room %s", req.Code) + if err := s.join(req); err != nil { + log.Errorf("could not restart room %s: %v", req.Code, err) + } + case err != nil: log.Errorf("room %s ended: %v", req.Code, err) - } else if req.OneTime { + case leftDeliberately: + log.Infof("left room %s", req.Code) + if err := removePersistedRoom(req.Code); err != nil { + log.Warnf("could not remove persisted room %s: %v", req.Code, err) + } + case req.OneTime: // finished on its own, not stopped — a one-shot fetch, don't // resume it on the next restart log.Infof("room %s finished", req.Code) if err := removePersistedRoom(req.Code); err != nil { log.Warnf("could not remove persisted room %s: %v", req.Code, err) } - } else { - // a non-one-time room only stops via an explicit Stop (i.e. - // "share stop", shutting the whole daemon down) — leave it - // persisted so it resumes next time the daemon starts. + default: + // only stops via an explicit Stop/leave/restart — if none of + // those set a flag above, this was "share stop" shutting the + // whole daemon down, so leave it persisted to resume next time log.Infof("room %s stopped", req.Code) } }() @@ -179,21 +246,142 @@ func (s *server) join(req joinRequest) error { return nil } +// stopRoom pauses one room without forgetting it: it stays in rooms.json +// and shows up (as stopped) in Status, so its code can be looked up and +// handed to restartRoom later. This is the single-room equivalent of +// "share stop" with no code, just scoped to one room. +func (s *server) stopRoom(code string) error { + s.mu.Lock() + room, ok := s.rooms[code] + s.mu.Unlock() + if !ok { + return fmt.Errorf("not in room %s", code) + } + room.client.Stop() + return nil +} + +// leave ends this room's participation and forgets it: it will not +// resume on the next daemon start, and won't be listed by Status. Works +// whether the room is currently active or already paused via stopRoom. +func (s *server) leave(code string) error { + s.mu.Lock() + room, active := s.rooms[code] + if active { + s.leaving[code] = true + } + s.mu.Unlock() + + if active { + room.client.Stop() // its completion goroutine removes the persisted entry + return nil + } + return forgetIfPersisted(code) +} + +func forgetIfPersisted(code string) error { + persisted, err := loadPersistedRooms() + if err != nil { + return err + } + for _, req := range persisted { + if req.Code == code { + return removePersistedRoom(code) + } + } + return fmt.Errorf("not in room %s", code) +} + +// restartRoom stops (if currently active) or picks back up (if already +// paused) one room with the exact settings it was originally joined +// with, without touching any other room. +func (s *server) restartRoom(code string) error { + s.mu.Lock() + room, active := s.rooms[code] + if active { + s.restarting[code] = true + } + s.mu.Unlock() + + if active { + room.client.Stop() // its completion goroutine rejoins with the same request + return nil + } + + persisted, err := loadPersistedRooms() + if err != nil { + return err + } + for _, req := range persisted { + if req.Code == code { + return s.join(req) + } + } + return fmt.Errorf("not in room %s", code) +} + func (s *server) stopAll() { s.mu.Lock() defer s.mu.Unlock() - for code, cl := range s.rooms { + for code, room := range s.rooms { log.Infof("leaving room %s", code) - cl.Stop() + room.client.Stop() } } +// status reports every active room plus every persisted-but-stopped +// room (paused via stopRoom, or resumed-but-not-yet-active), so a stopped +// room's code stays visible in "share status" for restarting later. func (s *server) status() []swarm.RoomStatus { s.mu.Lock() - defer s.mu.Unlock() statuses := make([]swarm.RoomStatus, 0, len(s.rooms)) - for _, cl := range s.rooms { - statuses = append(statuses, cl.Status()) + active := make(map[string]bool, len(s.rooms)) + for code, room := range s.rooms { + statuses = append(statuses, room.client.Status()) + active[code] = true + } + s.mu.Unlock() + + persisted, err := loadPersistedRooms() + if err != nil { + log.Warnf("could not load persisted rooms for status: %v", err) + return statuses + } + for _, req := range persisted { + if !active[req.Code] { + statuses = append(statuses, stoppedRoomStatus(req)) + } } return statuses } + +// stoppedRoomStatus reports what's on record for a paused room, so its +// display looks the same as a running one: relay address always, and +// file details for a seeding room (its files are always fully present +// locally, so their progress is trivially "complete"). A receiving +// room's file details aren't knowable until it reconnects and gets a +// manifest, so those are simply omitted rather than guessed at. +func stoppedRoomStatus(req joinRequest) swarm.RoomStatus { + files := make([]swarm.FileStatus, 0, len(req.Files)) + for _, path := range req.Files { + info, err := os.Stat(path) + if err != nil { + log.Debugf("could not stat %s for status: %v", path, err) + continue + } + chunks := swarm.NumChunks(info.Size()) + files = append(files, swarm.FileStatus{ + Name: filepath.Base(path), + Size: info.Size(), + HaveChunks: chunks, + TotalChunks: chunks, + Complete: true, + }) + } + return swarm.RoomStatus{ + Code: req.Code, + RelayAddress: req.RelayAddress, + Stopped: true, + Files: files, + } +} diff --git a/src/swarm/client.go b/src/swarm/client.go index 9770232..12a66cb 100644 --- a/src/swarm/client.go +++ b/src/swarm/client.go @@ -150,11 +150,16 @@ type FileStatus struct { Complete bool } -// RoomStatus summarizes one room this client is participating in. +// RoomStatus summarizes one room this client is participating in. A +// stopped room (Stopped true) has no live Client to report Peers from, +// but RelayAddress/Files are still filled in from what's on record so +// the display looks the same whether the room is running or paused. type RoomStatus struct { - Code string - Peers int - Files []FileStatus + Code string + RelayAddress string + Stopped bool + Peers int + Files []FileStatus } // Status reports this room's current code, peer count, and per-file @@ -179,9 +184,10 @@ func (cl *Client) Status() RoomStatus { } } return RoomStatus{ - Code: cl.opts.Code, - Peers: len(distinctPeers), - Files: files, + Code: cl.opts.Code, + RelayAddress: cl.opts.RelayAddress, + Peers: len(distinctPeers), + Files: files, } }