Add per-room stop/leave/restart, with a unified status display

- "share stop <code>" pauses just that room (kept in rooms.json and in
  "share status", so its code can still be looked up and resumed) without
  touching any other room or the daemon.
- "share leave <code>" forgets a room entirely — no bare "stop" collapses
  into "leave" anymore, since those need to be distinct: pausing must
  survive to be resumable, forgetting must not.
- "share restart <code>" now works whether the room is still active or
  already paused (falls back to the persisted list), rejoining with its
  original settings.
- "share status" shows the same layout for running and stopped rooms —
  an explicit running/stopped label, relay address, and file details in
  both cases. A stopped room's file details come from stat()ing its
  seed files (always locally complete); a stopped receiving room has no
  file list since that's only knowable once it reconnects.
This commit is contained in:
2026-07-20 15:21:40 +08:00
parent 84f3d62d68
commit 04613e6685
6 changed files with 325 additions and 33 deletions
BIN
View File
Binary file not shown.
+74 -11
View File
@@ -35,22 +35,34 @@ USAGE EXAMPLES:
Join a room to receive whatever's shared there (and help re-share it): Join a room to receive whatever's shared there (and help re-share it):
share secret-code share secret-code
See what rooms are currently active: See what rooms are currently active (or paused):
share status share status
Stop the background daemon (leaves every room it's in): Stop the background daemon (leaves every room it's in):
share stop 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) — Restart the background daemon (e.g. after upgrading the binary) —
rooms it was in resume automatically: rooms it was in resume automatically:
share restart share restart
Restart (or resume a paused) room on its own:
share restart secret-code
COMMANDS: COMMANDS:
send seed file(s) into a room (see options with: share send -h) 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) relay start your own relay (optional; see options with: share relay -h)
status show the rooms the background daemon is currently in status show the rooms the background daemon knows about
stop stop the background daemon and leave every room stop stop the daemon, or pause just one room if given a room code
restart stop and relaunch the background daemon 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) daemon run the background daemon in the foreground (mostly for debugging)
Run "share [command] -h" for the options of a given command. Run "share [command] -h" for the options of a given command.
@@ -87,6 +99,8 @@ func Run() error {
return runRelay(splice(args, i)) return runRelay(splice(args, i))
case "stop": case "stop":
return runStop(splice(args, i)) return runStop(splice(args, i))
case "leave":
return runLeave(splice(args, i))
case "restart": case "restart":
return runRestart(splice(args, i)) return runRestart(splice(args, i))
case "status": case "status":
@@ -244,12 +258,21 @@ func runRelay(args []string) error {
func runStop(args []string) error { func runStop(args []string) error {
fs := flag.NewFlagSet("stop", flag.ExitOnError) fs := flag.NewFlagSet("stop", flag.ExitOnError)
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintln(os.Stderr, "share stop") fmt.Fprintln(os.Stderr, "share stop [room-code]")
} }
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err 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 { if err := daemon.Stop(); err != nil {
fmt.Println(err) fmt.Println(err)
return nil return nil
@@ -258,10 +281,33 @@ func runStop(args []string) error {
return nil 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 { func runRestart(args []string) error {
fs := flag.NewFlagSet("restart", flag.ExitOnError) fs := flag.NewFlagSet("restart", flag.ExitOnError)
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintln(os.Stderr, "share restart [options]") fmt.Fprintln(os.Stderr, "share restart [options] [room-code]")
fs.PrintDefaults() fs.PrintDefaults()
} }
debug := fs.Bool("debug", false, "toggle debug mode for the relaunched daemon") debug := fs.Bool("debug", false, "toggle debug mode for the relaunched daemon")
@@ -269,6 +315,14 @@ func runRestart(args []string) error {
return err 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 daemon.IsRunning() {
if err := daemon.Stop(); err != nil { if err := daemon.Stop(); err != nil {
return fmt.Errorf("could not stop the running daemon: %w", err) return fmt.Errorf("could not stop the running daemon: %w", err)
@@ -300,16 +354,25 @@ func runStatus(args []string) error {
return nil return nil
} }
if len(rooms) == 0 { if len(rooms) == 0 {
fmt.Println("No active rooms.") fmt.Println("No rooms.")
return nil return nil
} }
for _, r := range rooms { for _, r := range rooms {
peerWord := "peers" state := "running"
if r.Peers == 1 { detail := fmt.Sprintf(", %d peer", r.Peers)
peerWord = "peer" 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 { for _, f := range r.Files {
state := "in progress" state := "in progress"
if f.Complete { if f.Complete {
+34
View File
@@ -95,6 +95,40 @@ func Stop() error {
return err 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 // WaitUntilStopped polls until the daemon is no longer reachable, or
// returns an error once timeout elapses. Useful right after Stop, since // returns an error once timeout elapses. Useful right after Stop, since
// its response is sent before the daemon has necessarily finished tearing // its response is sent before the daemon has necessarily finished tearing
+2 -1
View File
@@ -5,8 +5,9 @@ import "github.com/tzgyn/share/src/swarm"
// request/response are the only vocabulary spoken over the control // request/response are the only vocabulary spoken over the control
// socket between a CLI invocation and the background daemon. // socket between a CLI invocation and the background daemon.
type request struct { 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"` 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 // joinRequest asks the daemon to start participating in a room. Paths
+202 -14
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net" "net"
"os" "os"
"path/filepath"
"sync" "sync"
"time" "time"
@@ -12,9 +13,21 @@ import (
"github.com/tzgyn/share/src/swarm" "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 { type server struct {
mu sync.Mutex 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 // Serve runs the daemon: it listens on the control socket and services
@@ -41,7 +54,11 @@ func Serve() error {
} }
defer os.Remove(path) 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) log.Infof("daemon listening on %s", path)
// Resuming persisted rooms happens in the background, not before the // 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}) respond(conn, response{OK: true})
return false 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": case "stop":
respond(conn, response{OK: true}) respond(conn, response{OK: true})
return true return true
@@ -146,7 +196,7 @@ func (s *server) join(req joinRequest) error {
} }
s.mu.Lock() s.mu.Lock()
s.rooms[req.Code] = cl s.rooms[req.Code] = &activeRoom{client: cl, req: req}
s.mu.Unlock() s.mu.Unlock()
log.Infof("joined room %s", req.Code) log.Infof("joined room %s", req.Code)
@@ -156,22 +206,39 @@ func (s *server) join(req joinRequest) error {
go func() { go func() {
err := cl.Wait() err := cl.Wait()
s.mu.Lock() s.mu.Lock()
delete(s.rooms, req.Code) 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() 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) 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 // finished on its own, not stopped — a one-shot fetch, don't
// resume it on the next restart // resume it on the next restart
log.Infof("room %s finished", req.Code) log.Infof("room %s finished", req.Code)
if err := removePersistedRoom(req.Code); err != nil { if err := removePersistedRoom(req.Code); err != nil {
log.Warnf("could not remove persisted room %s: %v", req.Code, err) log.Warnf("could not remove persisted room %s: %v", req.Code, err)
} }
} else { default:
// a non-one-time room only stops via an explicit Stop (i.e. // only stops via an explicit Stop/leave/restart — if none of
// "share stop", shutting the whole daemon down) — leave it // those set a flag above, this was "share stop" shutting the
// persisted so it resumes next time the daemon starts. // whole daemon down, so leave it persisted to resume next time
log.Infof("room %s stopped", req.Code) log.Infof("room %s stopped", req.Code)
} }
}() }()
@@ -179,21 +246,142 @@ func (s *server) join(req joinRequest) error {
return nil 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() { func (s *server) stopAll() {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
for code, cl := range s.rooms { for code, room := range s.rooms {
log.Infof("leaving room %s", code) 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 { func (s *server) status() []swarm.RoomStatus {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock()
statuses := make([]swarm.RoomStatus, 0, len(s.rooms)) statuses := make([]swarm.RoomStatus, 0, len(s.rooms))
for _, cl := range s.rooms { active := make(map[string]bool, len(s.rooms))
statuses = append(statuses, cl.Status()) 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 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,
}
}
+13 -7
View File
@@ -150,11 +150,16 @@ type FileStatus struct {
Complete bool 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 { type RoomStatus struct {
Code string Code string
Peers int RelayAddress string
Files []FileStatus Stopped bool
Peers int
Files []FileStatus
} }
// Status reports this room's current code, peer count, and per-file // Status reports this room's current code, peer count, and per-file
@@ -179,9 +184,10 @@ func (cl *Client) Status() RoomStatus {
} }
} }
return RoomStatus{ return RoomStatus{
Code: cl.opts.Code, Code: cl.opts.Code,
Peers: len(distinctPeers), RelayAddress: cl.opts.RelayAddress,
Files: files, Peers: len(distinctPeers),
Files: files,
} }
} }