From 4f168a606151a6e928b96d7bb2931e062cf664eb Mon Sep 17 00:00:00 2001 From: chris bell Date: Fri, 23 Oct 2026 23:18:43 -0500 Subject: [PATCH] Design doc updates --- docs/design-doc.md | 117 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/design-doc.md diff --git a/docs/design-doc.md b/docs/design-doc.md new file mode 100644 index 0000000..63c851b --- /dev/null +++ b/docs/design-doc.md @@ -0,0 +1,117 @@ +## Context: + +vssm is a daemon (`vssmd`) that installs, supervises, and configures multiple +Vintage Story dedicated servers, plus a command-line client (`vssm`). The +daemon is I/O-bound: it spawns a handful of child processes, relays their +console streams, serves a small HTTP API, and occasionally downloads and +compresses files. The game servers do the heavy work. + +The daemon will be network-facing, will spawn processes, and will handle +user-supplied paths and (eventually) uploaded files. Memory safety and a +strong standard library for async I/O, HTTP, and process management matter +more than speed. + +## Quirks/Constraints: + +- Linux is the primary target, but Windows should be on the road map. +- The main development environment is NixOS, so there will be some Nix-specific nuances. +- Minimal dependencies, and no telemetry. +- .NET was chosen since the Vintage Story server binaries already require it to run, so it made sense to just use it for the backend. + +## Non-goals for first release: + +- Web UI (the API comes first, but web UI is second highest priority) +- User accounts and roles (this is likely to never happen) +- Declarative config (instances defined in a config file, again likely to never happen) +- Built-in TLS (use a reverse proxy if exposing it) +- Windows support in the first release +- Per instance mod management and a file manager (see "Still needs deciding", these are nice-to-haves) + +## Design decisions: + +Daemon and CLI tool are implemented in C# with .NET 10 using the open-source SDK and runtime, with ASP.NET Core for the HTTP layer. + +- The daemon owns the server instances as child processes, so they depend on it and should not keep running without it. The daemon always attempts a graceful shutdown of every running instance when it stops, crashes, or is asked to exit. If it dies without getting the chance (SIGKILL, out of memory kill, power loss), the leftovers are handled the next time it starts (see "Still needs deciding"). Running under systemd may also help here, but that needs to be verified when packaging. +- The web UI should be plain HTML/CSS and JS without a heavy framework. +- HTTP API will be versioned like `/api/v1` and served over a Unix socket and TCP. +- Authentication is a single token generated by the daemon (on first run, or regenerated at will) that you will import into the web UI. This keeps authentication simple, but may need to be looked at again. + +### Instances + +- Every instance has a human readable name and an auto-incrementing numeric ID. IDs are never reused, even after an instance is deleted. The API addresses instances by ID, and the CLI can look up an ID from a name. +- Names are limited to a simple set of characters and must be unique. A name is only a label and is never used to build a file path. +- Each instance lives in its own directory named by ID. vssm keeps its own files (`instance.json`, and a record of the running process) next to a `data/` directory, and `data/` is exactly what gets passed to the game as `--dataPath`. +- vssm never edits `serverconfig.json` itself. The game generates and owns that file. When an instance is created, vssm makes the data directory and calls the game with `--setconfig` to create the config with the initial values (name, port, world generation settings). Settings can only be changed by calling `--setconfig` again, and only while the instance is stopped. +- World generation settings only matter before the first start, since the first start creates the world. After that they are treated as read only. +- Launch settings (port, ip, max clients, data path, log path) are stored in `instance.json` and passed to the game as arguments on every start. vssm's value always wins over whatever is in the game's config. +- Server binaries live in `installs//`, one directory per version, shared by all instances and never modified after install. An install is extracted to a temporary directory and only renamed into place once it is complete. An install that is still used by an instance cannot be deleted. + +### Server states: + +```text +STOPPED -> STARTING start requested +STARTING -> RUNNING ready line observed in the server output +STARTING -> STOPPED process died or failed to launch, or the ready line was not seen before the startup timeout (reason: failed to start) +STARTING -> STOPPING stop requested before it became ready +RUNNING -> STOPPING stop requested +RUNNING -> STOPPED process exited without being asked to +STOPPING -> STOPPED process exited, or was killed after the timeout +``` + +Anything else is an invalid transition, and the API should answer with a conflict error instead of guessing. + +- Restart is an operation rather than a state. It runs as one API call: stop, wait for STOPPED, then start. +- All transitions for an instance go through one place, one at a time, so a start arriving during a stop cannot race. +- State is not saved to disk. After the daemon starts, every instance is STOPPED. + +### Stopping an instance: + +1. Send `/stop` to the server console and wait. +2. If it is still running after the timeout, send SIGTERM and wait. +3. If it is still running after the second timeout, send SIGKILL. + +Each timeout is configurable, and the first two should be generous because saving takes longer on bigger worlds. On 1.22.7, both `/stop` and SIGTERM did a full world save and exited with code 0. Only the last step risks losing data. The same sequence runs in parallel for every instance when the daemon shuts down. + +### Last exit record: + +Each instance stores a record of how it last stopped: the raw exit code (can be empty if it never started), the reason (requested, exited on its own, killed after timeout, failed to start), a timestamp, and a pointer to a crash log if one appeared. The reason comes from what the daemon knows, such as whether it asked for the stop. It is never worked out from the exit code alone, because the game can crash and still exit with 0. + +### Ready signal: + +An instance is RUNNING when this line shows up in its output (1.22.7): + +```text +[Server Event] Dedicated Server now running on Port 42420 and all ips! +``` + +The daemon matches on the "now running on Port" part and reads the port from it. Only the start of the line is matched, since the end changes with settings like `--ip`. The pattern is kept as data in one place, so it is easy to change if a game update rewords it. Starting a fresh world took around 27 to 29 seconds on my machine with no mods, so the startup timeout has to be generous and configurable. + +### Game server behavior (tested on 1.22.7): + +These are things I measured by hand, and they may change between game versions. + +- `--setconfig` exits with 200 when it works. A bad value crashes with "Critical error occurred" and exits with 0, so the exit code alone cannot detect failure. The result is checked by reading the config back. +- Arguments are always passed as `--name=value` in a single token. A bare `--setconfig` takes the next argument as its value and crashes, or launches a full server if nothing follows it. +- The game is always spawned with an argument list, never through a shell, and with absolute paths. +- Calls that are supposed to exit (like `--setconfig`) get a timeout, because a bad call can start a whole server instead. +- Console commands work over a plain pipe, so no PTY is needed. stdin, stdout and stderr must all be redirected, because a server started from an interactive terminal got suspended by the shell. +- With `--dataPath` set correctly, crash logs are written to `/Logs/server-crash.log`. +- Stopping a script does not stop servers it started in the background, so the daemon needs its own cleanup. + +## Still needs deciding: + +- What should happen when the daemon dies and the machine stays up, so servers may still be running as orphans? A second daemon could start a server on a world that an orphan is still writing to. + - Kill them + - Adopt them + - Refuse to start until it is resolved by hand + - This will be a daemon setting, default to be picked. Each running server's PID and start time needs to be recorded so leftovers can be identified safely, and never by process name. +- What should happen to servers after a reboot or power loss? + - Keep them stopped until started manually + - Start them back up (probably a per instance autostart setting) +- Does the CLI need the token, or does the Unix socket permission count as enough? +- Which game versions are supported? Older versions may need a different .NET runtime, which would mean a runtime path per instance. +- What does the game do when its stdin is closed? It decides whether a closed pipe could act as a safety net when the daemon dies. +- Should a startup timeout ever count as RUNNING, or always as a failed start? Currently it is a failed start, because otherwise a hung server shows as running. The downside is that a reworded ready line would make every start fail until the pattern is updated. +- Where do vssm's own backups live? Probably outside the instance directory so a delete does not take them along. +- Per instance mod management (the game has `--addModPath` which may help). +- Per instance file manager. Needs a lot of care: all paths resolved in one place, limited to specific folders, nothing outside the instance reachable.