feat(sidecar): protocol 8 — the asset plane (Asset Bridge cutover, 1 of 5) #44

Merged
whitlocktech merged 6 commits from edge into main 2026-09-14 23:09:15 +00:00
2 changed files with 161 additions and 12 deletions
Showing only changes of commit 60e6de55f3 - Show all commits

View File

@@ -115,6 +115,14 @@ use tracing_subscriber::EnvFilter;
/// to every connected client. The dumb-forwarder property is doing real work here: the sidecar /// to every connected client. The dumb-forwarder property is doing real work here: the sidecar
/// does not know what an asset is, and must not learn. /// does not know what an asset is, and must not learn.
/// ///
/// Phase 2 adds the first family that actually carries content: **`cliloc.table`**, served at
/// `GET /cliloc`. It pages — the shard cuts at a byte budget and the caller echoes a cursor back —
/// and the sidecar forwards it without keeping any of it, which matters more here than usual: the
/// payload is five megabytes of EA's strings out of the operator's own client, and the one copy of
/// it that should exist is the one the website imports. That phase also gave `assets.error` a
/// `code`, so the status a refusal maps to stops depending on the wording of a human-facing
/// sentence (see [`web::asset_error_status`]).
///
/// **No store migration**, again: nothing on this plane is an event, so nothing is persisted. /// **No store migration**, again: nothing on this plane is an event, so nothing is persisted.
pub const PROTOCOL_VERSION: u32 = 8; pub const PROTOCOL_VERSION: u32 = 8;

View File

@@ -141,6 +141,11 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
// the files on that host changed since the last import", and a cached answer to that is // the files on that host changed since the last import", and a cached answer to that is
// worse than no answer. // worse than no answer.
.route("/assets/sources", get(assets_sources)) .route("/assets/sources", get(assets_sources))
// The cliloc table (Protocol 8, phase 2): UO's id -> display-string map, read out of the
// shard's own client and paged. RPC for the same reason as the source gate, and one more:
// it is five megabytes of somebody else's copyrighted strings, which this process has no
// business holding a copy of. It forwards them and forgets them.
.route("/cliloc", get(cliloc_table))
.route_layer(middleware::from_fn_with_state(state.clone(), gate)); .route_layer(middleware::from_fn_with_state(state.clone(), gate));
let app = Router::new() let app = Router::new()
@@ -1345,18 +1350,7 @@ fn respond_assets(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>)
if kind == BUSY_KIND { if kind == BUSY_KIND {
(BUSY_STATUS, Json(value)) (BUSY_STATUS, Json(value))
} else if kind == "assets.error" { } else if kind == "assets.error" {
let reason = value (asset_error_status(&value), Json(value))
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("request rejected");
let code = if reason.contains("disabled") {
StatusCode::FORBIDDEN
} else {
StatusCode::BAD_REQUEST
};
(code, Json(value))
} else { } else {
(StatusCode::OK, Json(value)) (StatusCode::OK, Json(value))
} }
@@ -1372,6 +1366,85 @@ fn respond_assets(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>)
} }
} }
/// The status behind one `assets.error`.
///
/// Phase 2 gave the frame a `code`, and the reason is worth stating: phase 1 decided between 403
/// and 400 by looking for the word "disabled" **in the operator-facing sentence**. That works
/// until someone improves the wording, at which point a refusal quietly becomes a bad request and
/// an administrator goes hunting for a bug in a correctly-written call. The sentence is for a
/// human; the code is for this function.
///
/// The substring check survives as a fallback because an overlay is deployed independently of the
/// sidecar: a phase-1 shard paired with a phase-2 sidecar still sends the codeless frame, and it
/// must keep getting its 403.
fn asset_error_status(value: &Value) -> StatusCode {
match value.get("code").and_then(|c| c.as_str()) {
Some("DISABLED") => return StatusCode::FORBIDDEN,
// The shard has no such file. Not the caller's mistake and not a broken shard -- a client
// that does not carry what was asked for, which the website reports to its operator.
Some("NOT_FOUND") => return StatusCode::NOT_FOUND,
// It has the file and cannot decode it: truncated, hand-edited, or not what it claims to
// be. 422 rather than 400, because the request was fine and repeating it will not help.
Some("UNREADABLE") => return StatusCode::UNPROCESSABLE_ENTITY,
// The shard cannot do this right now (it could not start its asset worker, say). Same
// status as "no shard connected", because it means the same thing to a caller: come back.
Some("UNAVAILABLE") => return StatusCode::SERVICE_UNAVAILABLE,
Some("BAD_REQUEST") => return StatusCode::BAD_REQUEST,
_ => {}
}
let reason = value
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("request rejected");
if reason.contains("disabled") {
StatusCode::FORBIDDEN
} else {
StatusCode::BAD_REQUEST
}
}
/// The cliloc table, as the shard's own client holds it (docs/link/v8.md §9).
///
/// Until protocol 8 this table reached the website by hand: the operator installed UOFiddler,
/// built a converter against its `Ultima.dll`, ran it over their client's compressed `Cliloc.enu`
/// and copied the result to the web host. The shard could not help, because ServUO's bundled
/// `Ultima.StringList` cannot read a modern client's file either. Phase 2 put the decompressor in
/// the overlay, so the shard reads its own client and the operator installs nothing.
///
/// **Paged, and the caller drives the paging** -- `?cursor=` echoes back whatever the previous
/// reply's `cursor` was, until a reply says `more: false`. The pages are cut by byte budget on the
/// shard (512 KiB against the 1 MiB inbound line cap), so a stock English table arrives in about
/// eleven of them. The sidecar keeps none of it: it has no opinion about what a cliloc is, and a
/// cached copy of five megabytes of EA's strings is exactly what this process should not hold.
///
/// `?lang=` selects the file; it defaults to `enu` on the shard and the shard refuses anything its
/// `Ultima.Files` cannot resolve, which is a 404 rather than a 400.
async fn cliloc_table(
State(st): State<AppState>,
Query(q): Query<ClilocQuery>,
) -> impl IntoResponse {
let req_id = st.rpc.next_req_id();
let mut cmd = json!({"kind": "cliloc.table", "reqId": req_id});
if let Some(lang) = q.lang.as_deref().filter(|s| !s.is_empty()) {
cmd["lang"] = json!(lang);
}
if let Some(cursor) = q.cursor.as_deref().filter(|s| !s.is_empty()) {
cmd["cursor"] = json!(cursor);
}
respond_assets(st.rpc.call(&st.shard, cmd, &req_id).await)
}
#[derive(Deserialize)]
struct ClilocQuery {
lang: Option<String>,
cursor: Option<String>,
}
// ---- websocket ---- // ---- websocket ----
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse { async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
@@ -1480,6 +1553,74 @@ mod tests {
assert_eq!(respond_assets(Ok(value)).0, StatusCode::BAD_REQUEST); assert_eq!(respond_assets(Ok(value)).0, StatusCode::BAD_REQUEST);
} }
/// Phase 2's codes, each of which says something a 400 does not. `NOT_FOUND` is a client that
/// does not carry the file (an operator fact, not a bug); `UNREADABLE` is a file that is there
/// and cannot be decoded, where repeating the request cannot help; `UNAVAILABLE` is the shard
/// declining for now.
#[test]
fn an_asset_error_code_picks_the_status() {
let with = |code: &str| {
respond_assets(Ok(json!({
"kind": "assets.error",
"reqId": "r-1",
"code": code,
"reason": ""
})))
.0
};
assert_eq!(with("DISABLED"), StatusCode::FORBIDDEN);
assert_eq!(with("NOT_FOUND"), StatusCode::NOT_FOUND);
assert_eq!(with("UNREADABLE"), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(with("UNAVAILABLE"), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(with("BAD_REQUEST"), StatusCode::BAD_REQUEST);
// An unknown code is not a reason to invent a status.
assert_eq!(with("SOMETHING_NEW"), StatusCode::BAD_REQUEST);
}
/// The overlay and the sidecar ship separately and an operator can run a phase-1 shard against
/// a phase-2 sidecar, so the codeless refusal must keep its 403. This is the whole reason the
/// substring check survives rather than being deleted with the code it preceded.
#[test]
fn a_codeless_disabled_refusal_is_still_a_403() {
let value = json!({
"kind": "assets.error",
"reqId": "r-9",
"reason": "asset extraction is disabled on this shard"
});
assert_eq!(respond_assets(Ok(value)).0, StatusCode::FORBIDDEN);
}
/// One page of the cliloc table. The assertion that matters is the envelope: `more` and
/// `cursor` reach the caller untouched, because the sidecar does not page this — the shard
/// cuts the pages and the website drives them, and a sidecar that "helpfully" assembled them
/// would be holding the whole table in memory to do it.
#[test]
fn a_cliloc_page_is_a_200_and_keeps_its_cursor() {
let value = json!({
"kind": "cliloc.table.ok",
"reqId": "r-2",
"lang": "enu",
"extractorVersion": 1,
"total": 67496,
"rows": [{"n": 1023721, "f": 0, "t": "quarter staff"}],
"more": true,
"cursor": "n:1023721",
"cut": "budget"
});
let (status, body) = respond_assets(Ok(value));
assert_eq!(status, StatusCode::OK);
assert_eq!(body.0.get("more").and_then(|v| v.as_bool()), Some(true));
assert_eq!(
body.0.get("cursor").and_then(|v| v.as_str()),
Some("n:1023721")
);
assert_eq!(body.0.get("total").and_then(|v| v.as_i64()), Some(67496));
}
/// A source manifest comes back whole. Worth asserting because `respond_assets` sniffs `kind` /// A source manifest comes back whole. Worth asserting because `respond_assets` sniffs `kind`
/// and a family whose success kind ends in `.ok` sits one character away from the `.error` /// and a family whose success kind ends in `.ok` sits one character away from the `.error`
/// suffix the generic responder matches on -- which is exactly why this plane has its own /// suffix the generic responder matches on -- which is exactly why this plane has its own