hachem.wtf
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-29 19:15:28 +0200
committerhachem <im@hachem.wtf>2026-08-29 19:15:28 +0200
commit3eba8fbd243c0ffcb1c396526a4e19d1abc1eaf9 (patch)
tree31613c6da3a3da3c8777d094f5e1466804dbf3a9
parentfcbbeccaa80c7e120933682b330272a1490c1cf5 (diff)
add: control flow
-rw-r--r--internal/cli/commands.go20
-rw-r--r--internal/cli/server.go84
-rw-r--r--internal/server/ping.go134
-rw-r--r--internal/server/server.go196
-rw-r--r--internal/server/sysproc_unix.go36
-rw-r--r--internal/server/sysproc_windows.go33
6 files changed, 503 insertions, 0 deletions
diff --git a/internal/cli/commands.go b/internal/cli/commands.go
index d76402d..437fff2 100644
--- a/internal/cli/commands.go
+++ b/internal/cli/commands.go
@@ -17,6 +17,26 @@ func defaultCommands() []command {
run: runMods,
},
{
+ name: "start",
+ desc: "Start the server in the background",
+ run: runStart,
+ },
+ {
+ name: "stop",
+ desc: "Stop the running server",
+ run: runStop,
+ },
+ {
+ name: "restart",
+ desc: "Restart the server",
+ run: runRestart,
+ },
+ {
+ name: "status",
+ desc: "Show whether the server is running, players, and version",
+ run: runStatus,
+ },
+ {
name: "check",
desc: "Check whether the configured mods are compatible",
run: runCheck,
diff --git a/internal/cli/server.go b/internal/cli/server.go
new file mode 100644
index 0000000..9ed0fea
--- /dev/null
+++ b/internal/cli/server.go
@@ -0,0 +1,84 @@
+package cli
+
+import (
+ "fmt"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/hachem-wtf/benson/internal/config"
+ "github.com/hachem-wtf/benson/internal/server"
+)
+
+func runStart(m *model) tea.Cmd {
+ pid, err := server.Start(m.cfg)
+ if err != nil {
+ return tea.Println(errorStyle.Render(err.Error()))
+ }
+ return tea.Println(successStyle.Render("Server started ") +
+ mutedStyle.Render(fmt.Sprintf("(pid %d) · logs: %s", pid, server.LogPath(m.cfg))))
+}
+
+func runStop(m *model) tea.Cmd {
+ return m.runServerJob(func(cfg *config.Config, emit func(string)) tea.Msg {
+ if err := server.Stop(cfg, emit); err != nil {
+ return streamDoneMsg{errorStyle.Render(err.Error())}
+ }
+ return streamDoneMsg{successStyle.Render("Server stopped.")}
+ })
+}
+
+func runRestart(m *model) tea.Cmd {
+ return m.runServerJob(func(cfg *config.Config, emit func(string)) tea.Msg {
+ if err := server.Restart(cfg, emit); err != nil {
+ return streamDoneMsg{errorStyle.Render(err.Error())}
+ }
+ return streamDoneMsg{successStyle.Render("Server restarted.")}
+ })
+}
+
+func runStatus(m *model) tea.Cmd {
+ m.busy = true
+ cfg := m.cfg
+ return tea.Batch(m.spin.Tick, func() tea.Msg {
+ return outputMsg{text: renderStatus(server.GetStatus(cfg))}
+ })
+}
+
+func (m *model) runServerJob(job func(cfg *config.Config, emit func(string)) tea.Msg) tea.Cmd {
+ m.busy = true
+ ch := make(chan tea.Msg, 64)
+ m.events = ch
+ cfg := m.cfg
+ go func() {
+ emit := func(line string) { ch <- streamMsg{" " + mutedStyle.Render(line)} }
+ ch <- job(cfg, emit)
+ }()
+ return tea.Batch(m.spin.Tick, waitForEvent(ch))
+}
+
+func renderStatus(s server.Status) string {
+ rows := []string{headingStyle.Render("Server status"), ""}
+
+ if s.Running {
+ rows = append(rows, " "+successStyle.Render("● running")+mutedStyle.Render(fmt.Sprintf(" (pid %d)", s.PID)))
+ } else {
+ rows = append(rows, " "+errorStyle.Render("● stopped"))
+ }
+
+ rows = append(rows, " "+mutedStyle.Render("address ")+s.Address)
+
+ if s.Reachable {
+ rows = append(rows,
+ " "+mutedStyle.Render("version ")+s.Version,
+ " "+mutedStyle.Render("players ")+fmt.Sprintf("%d / %d", s.Players, s.MaxPlayers),
+ )
+ if s.MOTD != "" {
+ rows = append(rows, " "+mutedStyle.Render("motd ")+s.MOTD)
+ }
+ } else if s.Running {
+ rows = append(rows, " "+warnStyle.Render("not yet accepting connections (still loading?)"))
+ }
+
+ return lipgloss.JoinVertical(lipgloss.Left, rows...)
+}
diff --git a/internal/server/ping.go b/internal/server/ping.go
new file mode 100644
index 0000000..4783ec7
--- /dev/null
+++ b/internal/server/ping.go
@@ -0,0 +1,134 @@
+package server
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "time"
+)
+
+type pingResponse struct {
+ Version struct {
+ Name string `json:"name"`
+ } `json:"version"`
+ Players struct {
+ Online int `json:"online"`
+ Max int `json:"max"`
+ } `json:"players"`
+ Description json.RawMessage `json:"description"`
+}
+
+func (p pingResponse) motdText() string {
+ var text string
+ if json.Unmarshal(p.Description, &text) == nil {
+ return text
+ }
+
+ var obj struct {
+ Text string `json:"text"`
+ }
+ if json.Unmarshal(p.Description, &obj) == nil {
+ return obj.Text
+ }
+ return ""
+}
+
+func pingServer(host string, port int, timeout time.Duration) (*pingResponse, error) {
+ conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout)
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(timeout))
+
+ var handshake bytes.Buffer
+ writeVarInt(&handshake, 0x00)
+ writeVarInt(&handshake, -1)
+ writeString(&handshake, host)
+ binary.Write(&handshake, binary.BigEndian, uint16(port))
+ writeVarInt(&handshake, 1)
+ if err := writePacket(conn, handshake.Bytes()); err != nil {
+ return nil, err
+ }
+
+ if err := writePacket(conn, []byte{0x00}); err != nil {
+ return nil, err
+ }
+
+ reader := bufio.NewReader(conn)
+ if _, err := readVarInt(reader); err != nil {
+ return nil, err
+ }
+ packetID, err := readVarInt(reader)
+ if err != nil {
+ return nil, err
+ }
+ if packetID != 0x00 {
+ return nil, fmt.Errorf("unexpected packet id %d", packetID)
+ }
+
+ jsonLen, err := readVarInt(reader)
+ if err != nil {
+ return nil, err
+ }
+ payload := make([]byte, jsonLen)
+ if _, err := io.ReadFull(reader, payload); err != nil {
+ return nil, err
+ }
+
+ var resp pingResponse
+ if err := json.Unmarshal(payload, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+func writePacket(conn net.Conn, data []byte) error {
+ var buf bytes.Buffer
+ writeVarInt(&buf, len(data))
+ buf.Write(data)
+ _, err := conn.Write(buf.Bytes())
+ return err
+}
+
+func writeString(buf *bytes.Buffer, s string) {
+ writeVarInt(buf, len(s))
+ buf.WriteString(s)
+}
+
+func writeVarInt(buf *bytes.Buffer, value int) {
+ uv := uint32(value)
+ for {
+ if uv&^uint32(0x7F) == 0 {
+ buf.WriteByte(byte(uv))
+ return
+ }
+ buf.WriteByte(byte(uv&0x7F | 0x80))
+ uv >>= 7
+ }
+}
+
+func readVarInt(r io.ByteReader) (int, error) {
+ var result uint32
+ var shift uint
+ for {
+ b, err := r.ReadByte()
+ if err != nil {
+ return 0, err
+ }
+ result |= uint32(b&0x7F) << shift
+ if b&0x80 == 0 {
+ break
+ }
+ shift += 7
+ if shift >= 32 {
+ return 0, errors.New("varint too long")
+ }
+ }
+ return int(result), nil
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..8e2019d
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,196 @@
+package server
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/hachem-wtf/benson/internal/config"
+)
+
+const (
+ stopTimeout = 60 * time.Second
+ pingTimeout = 2 * time.Second
+)
+
+type Status struct {
+ Running bool
+ PID int
+ Reachable bool
+ Version string
+ MOTD string
+ Players int
+ MaxPlayers int
+ Address string
+}
+
+func pidPath(cfg *config.Config) string {
+ return filepath.Join(cfg.ServerLocation, "benson-server.pid")
+}
+
+func LogPath(cfg *config.Config) string {
+ return filepath.Join(cfg.ServerLocation, "benson-server.log")
+}
+
+func javaCmd(cfg *config.Config) string {
+ if cfg.JavaPath != "" {
+ return cfg.JavaPath
+ }
+ return "java"
+}
+
+func readPID(cfg *config.Config) (int, bool) {
+ data, err := os.ReadFile(pidPath(cfg))
+ if err != nil {
+ return 0, false
+ }
+ pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
+ if err != nil {
+ return 0, false
+ }
+ return pid, true
+}
+
+func Running(cfg *config.Config) (int, bool) {
+ pid, ok := readPID(cfg)
+ if !ok {
+ return 0, false
+ }
+ if !isAlive(pid) {
+ os.Remove(pidPath(cfg))
+ return 0, false
+ }
+ return pid, true
+}
+
+func Start(cfg *config.Config) (int, error) {
+ if cfg.ServerLocation == "" {
+ return 0, errors.New("server_location must be set in config")
+ }
+ if pid, ok := Running(cfg); ok {
+ return 0, fmt.Errorf("server already running (pid %d)", pid)
+ }
+
+ jar := filepath.Join(cfg.ServerLocation, "server.jar")
+ if _, err := os.Stat(jar); err != nil {
+ return 0, errors.New("no server.jar in server_location — run generate and merge first")
+ }
+
+ eula := filepath.Join(cfg.ServerLocation, "eula.txt")
+ if _, err := os.Stat(eula); os.IsNotExist(err) {
+ if err := os.WriteFile(eula, []byte("eula=true\n"), 0o644); err != nil {
+ return 0, err
+ }
+ }
+
+ logFile, err := os.Create(LogPath(cfg))
+ if err != nil {
+ return 0, err
+ }
+ defer logFile.Close()
+
+ cmd := exec.Command(javaCmd(cfg), "-jar", "server.jar", "nogui")
+ cmd.Dir = cfg.ServerLocation
+ cmd.Stdout = logFile
+ cmd.Stderr = logFile
+ cmd.SysProcAttr = detachAttr()
+
+ if err := cmd.Start(); err != nil {
+ return 0, err
+ }
+
+ pid := cmd.Process.Pid
+ if err := os.WriteFile(pidPath(cfg), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
+ return pid, err
+ }
+ go cmd.Wait()
+ return pid, nil
+}
+
+func Stop(cfg *config.Config, progress func(string)) error {
+ pid, ok := Running(cfg)
+ if !ok {
+ return errors.New("server is not running")
+ }
+
+ progress(fmt.Sprintf("Sending stop signal to pid %d…", pid))
+ if err := signalStop(pid); err != nil {
+ return err
+ }
+
+ progress("Waiting for the server to save and shut down…")
+ deadline := time.Now().Add(stopTimeout)
+ for time.Now().Before(deadline) {
+ if !isAlive(pid) {
+ os.Remove(pidPath(cfg))
+ return nil
+ }
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ progress("Timed out — forcing shutdown…")
+ forceKill(pid)
+ os.Remove(pidPath(cfg))
+ return nil
+}
+
+func Restart(cfg *config.Config, progress func(string)) error {
+ if _, ok := Running(cfg); ok {
+ if err := Stop(cfg, progress); err != nil {
+ return err
+ }
+ time.Sleep(1 * time.Second)
+ }
+
+ pid, err := Start(cfg)
+ if err != nil {
+ return err
+ }
+ progress(fmt.Sprintf("Server started (pid %d).", pid))
+ return nil
+}
+
+func GetStatus(cfg *config.Config) Status {
+ pid, running := Running(cfg)
+ port := serverPort(cfg)
+
+ s := Status{
+ Running: running,
+ PID: pid,
+ Address: fmt.Sprintf("localhost:%d", port),
+ }
+
+ if ping, err := pingServer("localhost", port, pingTimeout); err == nil {
+ s.Reachable = true
+ s.Version = ping.Version.Name
+ s.Players = ping.Players.Online
+ s.MaxPlayers = ping.Players.Max
+ s.MOTD = ping.motdText()
+ }
+ return s
+}
+
+func serverPort(cfg *config.Config) int {
+ f, err := os.Open(filepath.Join(cfg.ServerLocation, "server.properties"))
+ if err != nil {
+ return 25565
+ }
+ defer f.Close()
+
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if value, ok := strings.CutPrefix(line, "server-port="); ok {
+ if port, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
+ return port
+ }
+ }
+ }
+ return 25565
+}
diff --git a/internal/server/sysproc_unix.go b/internal/server/sysproc_unix.go
new file mode 100644
index 0000000..91dee0e
--- /dev/null
+++ b/internal/server/sysproc_unix.go
@@ -0,0 +1,36 @@
+//go:build !windows
+
+package server
+
+import (
+ "os"
+ "syscall"
+)
+
+func detachAttr() *syscall.SysProcAttr {
+ return &syscall.SysProcAttr{Setsid: true}
+}
+
+func signalStop(pid int) error {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+ return proc.Signal(syscall.SIGTERM)
+}
+
+func forceKill(pid int) error {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+ return proc.Kill()
+}
+
+func isAlive(pid int) bool {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ return proc.Signal(syscall.Signal(0)) == nil
+}
diff --git a/internal/server/sysproc_windows.go b/internal/server/sysproc_windows.go
new file mode 100644
index 0000000..0f7977f
--- /dev/null
+++ b/internal/server/sysproc_windows.go
@@ -0,0 +1,33 @@
+//go:build windows
+
+package server
+
+import (
+ "os"
+ "syscall"
+)
+
+func detachAttr() *syscall.SysProcAttr {
+ return &syscall.SysProcAttr{}
+}
+
+func signalStop(pid int) error {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+ return proc.Kill()
+}
+
+func forceKill(pid int) error {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+ return proc.Kill()
+}
+
+func isAlive(pid int) bool {
+ _, err := os.FindProcess(pid)
+ return err == nil
+}