diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b8c1c77..61cbcd5 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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' } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index efad008..04daa86 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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. diff --git a/internal/api/handlers_internal.go b/internal/api/handlers_internal.go index 1423763..e0954fd 100644 --- a/internal/api/handlers_internal.go +++ b/internal/api/handlers_internal.go @@ -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 { diff --git a/internal/api/handlers_maintenance_test.go b/internal/api/handlers_maintenance_test.go index 31e688d..2a84133 100644 --- a/internal/api/handlers_maintenance_test.go +++ b/internal/api/handlers_maintenance_test.go @@ -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. diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 9112c8f..8302216 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -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; diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java index 8d4d011..22a6e91 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -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 { diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/MotdResponder.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/MotdResponder.java index e2732c2..bc56b39 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/MotdResponder.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/MotdResponder.java @@ -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) { diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java index 582bd57..7ba0e98 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/WaitingRouter.java @@ -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 diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/ServerRegistryTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/ServerRegistryTest.java index 6f873a1..6eb7d7a 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/ServerRegistryTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/ServerRegistryTest.java @@ -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); } diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java index 3779798..6fed806 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/WaitingRouterTest.java @@ -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");