fix(velocity): 休眠服 MOTD 按唤醒策略说明谁能唤醒,闲置回收期间加入给出回收提示

This commit is contained in:
Lemon-miaow committed 2026-09-27 15:02:16 +08:00
1 parent 249d675bd7
commit 1e42e9a87b
10 files changed
+76 -7

No files matched your search

+3 -1
View File
@@ -1276,7 +1276,9 @@ paths:
start failed and its automatic retries are spent (ServerInfo.startGaveUp);
the server stays down until a person starts it from the panel.
server_retiring: the owner gave the server up or an admin is deleting it;
it stays down until the reaper archives it.
it stays down until the reaper archives it. world_reclaiming: the idle
reaper is archiving the world; afterwards the server is released with an
empty world.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
+3 -1
View File
@@ -221,6 +221,7 @@ per-server cooldown → global running cap**. Map the API result:
|---|---|---|---|
| `403` | `forbidden` | `autostartPolicy=allowlist` and UUID not allowlisted, or `ownerOnly` and caller is not owner | Add the UUID / claim the server / set `autostartPolicy=public` |
| `409` | `maintenance_in_progress` | A restore, backup or file write holds the server's world volume (§3b) | Wait for the Job to finish |
| `409` | `world_reclaiming` | The idle reaper is archiving the world (§3b item 3); afterwards the server is released with an empty world | Nothing to wait for; the old world stays in the archive |
| `429` | (cooldown) | Wake retried within the 30s per-server `WakeCooldown` | Wait out the cooldown |
| `503` | `at_capacity` | Global `MaxRunningServers` cap reached | Stop another server or raise the cap |
@@ -231,7 +232,8 @@ teleports when `ready=true`.
The Velocity-side consumption of these codes (`403` → "You're not allowed to
start «server»"; `409 maintenance_in_progress` → "«server» is under
maintenance", not queued; `429` → re-queue; other → "Couldn't start … Try again
maintenance", not queued; `409 world_reclaiming` → "«server» sat idle too
long and its world is being archived", not queued; `429` → re-queue; other → "Couldn't start … Try again
shortly.") lives in the Java plugin and is **[CODE-ONLY]** — the codes it reacts
to are produced by the Go-tested `authorizeWakeByUUID` / cooldown limiter, so
grade the two halves separately.
+16 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming"
)
@@ -192,8 +193,16 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
}
// A 409 maintenance_in_progress tells velocity nothing is coming up until the
// restore/backup/file write finishes, so it does not enqueue the player.
// restore/backup/file write finishes, so it does not enqueue the player. The
// idle reaper gets its own code: when it lets go, the world is archived and the
// server released, so "try again shortly" would send the player back to a server
// that is no longer the one they knew.
if err := a.Cluster.SetDesiredState(r.Context(), name, v1alpha1.DesiredRunning); err != nil {
var busy *MaintenanceBusyError
if errors.As(err, &busy) && busy.Kind == maintenance.KindReap {
writeError(w, r, errWorldReclaiming)
return
}
a.writeLookupError(w, r, err)
return
}
@@ -210,6 +219,12 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
})
}
// errWorldReclaiming refuses a join-driven wake while the idle reaper archives the
// server's world. Once it is done the server is released with an empty world and
// the old one stays in the archive.
var errWorldReclaiming = newError(http.StatusConflict, "world_reclaiming",
"this server sat idle too long and its world is being archived; afterwards it is released with an empty world")
// internalClaimRequest is the velocity `Claim & Start` body: the verified
// online-mode UUID of the player claiming an ownerless server (spec §9.3, §12).
type internalClaimRequest struct {
+14
View File
@@ -58,6 +58,20 @@ func TestWakeRefusedDuringMaintenance(t *testing.T) {
t.Fatalf("wake after maintenance: code = %d body %s", w.Code, w.Body.String())
}
})
// The idle reaper is no wait-and-retry: when it lets go the world is archived
// and the server released, so velocity must be able to tell the player that.
t.Run("internal wake during an idle reap -> 409 world_reclaiming", func(t *testing.T) {
api, cl := newInternalWakeAPI("public")
cl.wakeErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindReap}
w := internalWake(api, `{"mc_uuid":"`+wakeUUID+`"}`)
if w.Code != http.StatusConflict || decodeErr(t, w) != "world_reclaiming" {
t.Fatalf("code = %d body %s, want 409 world_reclaiming", w.Code, w.Body.String())
}
if _, set := cl.desired["survival"]; set {
t.Fatal("a refused wake must not flip desiredState")
}
})
}
// maintenanceOp is one world-volume operation as the external face serves it.
+1 -1
View File
@@ -3280,7 +3280,7 @@ export interface operations {
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
404: components["responses"]["NotFound"];
/** @description Nothing was started. maintenance_in_progress: a restore, backup or file write holds the server's world volume. start_failed: the last start failed and its automatic retries are spent (ServerInfo.startGaveUp); the server stays down until a person starts it from the panel. server_retiring: the owner gave the server up or an admin is deleting it; it stays down until the reaper archives it. */
/** @description Nothing was started. maintenance_in_progress: a restore, backup or file write holds the server's world volume. start_failed: the last start failed and its automatic retries are spent (ServerInfo.startGaveUp); the server stays down until a person starts it from the panel. server_retiring: the owner gave the server up or an admin is deleting it; it stays down until the reaper archives it. world_reclaiming: the idle reaper is archiving the world; afterwards the server is released with an empty world. */
409: {
headers: {
[name: string]: unknown;
@@ -84,7 +84,7 @@ public final class FelisApiClient {
* joining player (spec §9.1, §14). The reply (202) carries the current phase
* and ready flag so the caller can decide whether to wait. A 403 (policy gate),
* 409 {@code maintenance_in_progress} (a restore, backup or file write holds the
* world), 429 (cooldown), or 503 {@code at_capacity} (running cap) arrives as a
* world) or {@code world_reclaiming} (the idle reaper is archiving it), 429 (cooldown), or 503 {@code at_capacity} (running cap) arrives as a
* LinkException the caller branches on.
*/
public ServerView wake(String name, UUID mcUuid) throws LinkException {
@@ -74,7 +74,16 @@ public final class MotdResponder {
if ("Running".equals(v.desiredState())) {
return "启动中… / starting…";
}
return "休眠中,加入即唤醒 / sleeping — join to wake";
// The ping carries no identity either, so a sleeping server says who can wake
// it, by the same autostartPolicy gate the join runs into: ownerOnly or unset
// admits the owner alone.
if ("public".equals(v.autostartPolicy())) {
return "休眠中,加入即唤醒 / sleeping — join to wake";
}
if ("allowlist".equals(v.autostartPolicy())) {
return "休眠中,名单内玩家加入即唤醒 / sleeping — allowlisted players can wake it";
}
return "休眠中,仅服主可唤醒 / sleeping — only the owner can wake it";
}
static NamedTextColor statusColor(ServerView v) {
@@ -727,6 +727,18 @@ public final class WaitingRouter {
NamedTextColor.YELLOW));
return;
}
if ("world_reclaiming".equals(e.errorCode())) {
// The idle reaper is archiving the world. When it is done the
// server is released with an empty world, so there is nothing
// to wait for, and "try again shortly" would mislead.
player.sendMessage(Component.text(
zh ? "「" + serverName + "」闲置太久,世界正在归档回收,现在不能启动。"
+ "回收后它会变成可认领的空服,旧世界留在归档里。"
: "« " + serverName + " » sat idle too long and its world is being archived, so it can't start."
+ " Afterwards it becomes a claimable empty server; the old world stays in the archive.",
NamedTextColor.YELLOW));
return;
}
if ("server_retiring".equals(e.errorCode())) {
// The owner gave the server up or an admin is deleting it: it
// stays down until the reaper archives it, unless that is
@@ -156,8 +156,16 @@ public final class ServerRegistryTest {
assertEq("motd: retries spent", "启动失败,等服主处理 / failed to start — the owner has to restart it",
MotdResponder.statusLine(gaveUp));
assertEq("motd: retries spent is red", NamedTextColor.RED, MotdResponder.statusColor(gaveUp));
assertEq("motd: sleeping", "休眠中,加入即唤醒 / sleeping — join to wake",
// A sleeping server says who can wake it: a stranger reading "join to wake" on
// an ownerOnly server joined only to be refused.
assertEq("motd: sleeping, ownerOnly", "休眠中,仅服主可唤醒 / sleeping — only the owner can wake it",
MotdResponder.statusLine(down("a", "a")));
assertEq("motd: sleeping, public", "休眠中,加入即唤醒 / sleeping — join to wake",
MotdResponder.statusLine(sleeping("public")));
assertEq("motd: sleeping, allowlist", "休眠中,名单内玩家加入即唤醒 / sleeping — allowlisted players can wake it",
MotdResponder.statusLine(sleeping("allowlist")));
assertEq("motd: sleeping, no policy", "休眠中,仅服主可唤醒 / sleeping — only the owner can wake it",
MotdResponder.statusLine(sleeping(null)));
System.out.println("ServerRegistryTest OK (" + checks + " checks)");
}
@@ -170,6 +178,10 @@ public final class ServerRegistryTest {
return new ServerView(name, sub, "Stopped", false, "ownerOnly", "Stopped", "fallback", "login", 0, 0);
}
private static ServerView sleeping(String policy) {
return new ServerView("a", "a", "Stopped", false, policy, "Stopped", "fallback", "login", 0, 0);
}
private static ServerView unstarted(String name, String sub) {
return new ServerView(name, sub, "", false, "ownerOnly", "Stopped", null, null, 0, 0);
}
@@ -234,6 +234,7 @@ public final class WaitingRouterTest {
{"retries spent", "« gamma » failed to start and its automatic retries are spent", null},
{"409 maintenance_in_progress", "« gamma » is under maintenance", null},
{"409 server_retiring", "« gamma » has been given up or is being deleted", null},
{"409 world_reclaiming", "« gamma » sat idle too long and its world is being archived", null},
{"409 conflict", "Couldn't start « gamma » right now", "wake gamma failed (status=409)"},
{"503 at_capacity", "The cluster is at capacity right now", null},
{"503 unavailable", "Couldn't start « gamma » right now", "wake gamma failed (status=503)"},
@@ -259,6 +260,8 @@ public final class WaitingRouterTest {
assertEq(c[0] + ": no promise of a start", false, p.said("Starting « gamma »"));
if (c[2] != null) {
assertEq(c[0] + ": logged", warned + 1, log.count("WARN", c[2]));
} else {
assertEq(c[0] + ": no generic failure on top", false, p.said("Couldn't start « gamma » right now"));
}
api.policy.remove("gamma");
api.phase.remove("gamma");