21 Commits

Author SHA1 Message Date
82872ffba7 Merge pull request 'feat(sidecar): protocol 8 — the asset plane (Asset Bridge cutover, 1 of 5)' (#44) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 7s
SonarQube / analysis (push) Failing after -53s
Release sidecar / release (push) Successful in 12m2s
Reviewed-on: #44
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-14 23:09:12 +00:00
baa04e1a76 Merge pull request 'feat(sidecar): forward the asset manifest, the pixels and the body pass (Phase 3)' (#43) from feat/asset-bridge-p3 into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m52s
Reviewed-on: #43
2026-09-10 23:57:55 +00:00
143f424867 feat(sidecar): forward the asset manifest, the pixels and the body pass (Phase 3)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m24s
Three routes, forwarded verbatim like everything else on this link:

  GET  /assets/manifest?family=&cursor=
  POST /assets/fetch
  POST /assets/bodies

**The two POSTs are reads.** The method is the request body, not a side
effect -- a few hundred asset keys do not belong in a query string, and these
are the only reads on this link that take one. `assets_call` is `event_call`'s
shape with one difference that matters: it responds through `respond_assets`,
so `bridge.busy` is a 425 rather than an idempotency collision. On this plane
busy is the ORDINARY answer during an import, and a caller that read it as an
error would abandon a healthy transfer.

422 gains a second meaning here alongside "the shard cannot decode that file":
the mid-import guard. A manifest reply carries a `catalog` id the shard derives
from its own client files, and passing it back on a fetch makes the shard refuse
if those files moved in between -- without which an operator patching their
client halfway through an import gets one asset set stitched out of two, with no
error anywhere.

v8.md §16 listed phase 3 as servuo-plugins + module-uo. That was wrong: web.rs
routes every command explicitly and has no generic /assets/* forwarder, so this
repo is in the phase. The doc now says so.

64 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 18:40:26 -05:00
60e6de55f3 Merge pull request 'feat(sidecar): forward the cliloc table, and stop reading refusals for meaning (Phase 2)' (#42) from feat/asset-bridge-p2 into edge
Reviewed-on: #42
2026-09-10 16:20:00 +00:00
b92393d224 feat(sidecar): forward the cliloc table, and stop reading refusals for meaning (Phase 2)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m2s
`GET /cliloc` — the first protocol-8 family that carries content rather than a
manifest. The shard decompresses its own client's table and cuts it into pages;
this forwards them and keeps none 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 that should exist is the one the website imports.

Paging is the caller's, deliberately. `?cursor=` echoes back the previous reply's
cursor until one says `more: false`; a sidecar that helpfully assembled the pages
would be holding the whole table in memory to do it. `?lang=` selects the file
and defaults on the shard.

`asset_error_status` replaces phase 1's substring test. That test chose 403 or
400 by looking for the word "disabled" in an operator-facing sentence, so
rewording the message would silently turn a refusal into a bad request. The
overlay now sends a `code`: DISABLED 403, NOT_FOUND 404 (a client without the
file — an operator fact, not a bug), UNREADABLE 422 (a file it has and cannot
decode, where repeating the request cannot help), UNAVAILABLE 503, BAD_REQUEST
400. The substring check survives as a fallback, with a test, because an overlay
and a sidecar are deployed separately and a phase-1 shard must keep its 403.

No `PROTOCOL_VERSION` change: 8 already covers this family (v8.md §14).

Verified against a live shard: 12 pages, 67,496 rows, every page inside the
512 KiB budget (max 524,086 of 524,288) and well under the 1 MiB line cap, the
whole table in 1.4 s. Concurrent callers get 425 while one is served, which is
the flow control working rather than an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 11:13:03 -05:00
6d83df0a2c Merge pull request 'feat(sidecar): protocol 8 — the asset plane, and a bound on what the shard can send' (#41) from feat/asset-bridge-p1 into edge
Reviewed-on: #41
2026-09-10 15:04:24 +00:00
a8f1804de9 feat(sidecar): protocol 8 — the asset plane, and a bound on what the shard can send
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m39s
Asset Bridge phase 1, sidecar half (docs/link/v8.md §3.3, §14).
Shard half: RunicGateway/servuo-plugins#28. Docs half: RunicGateway/docs#236.

Three things, one of which is not additive.

## The inbound line cap (§3.3) — the one that matters

`read_line` had **no bound at all**. That was survivable only because the shard had
never had a reason to send a large line. Protocol 8 gives it one deliberately, and an
unbounded read facing a component that now sends megabytes is a memory-exhaustion
shape we would be inventing ourselves.

`MAX_INBOUND_LINE_BYTES` is **1 MiB** — symmetric with the cap `BridgeLink.cs` has
always applied to its own inbound lines, so both directions of this link now read the
same. The shard's batch budget is 512 KiB, and the factor of two is load-bearing: a
page always admits its first item even when that item alone exceeds the budget (the
alternative is an oversized item skipped for the budget on every page forever), so the
wire needs room for one overshoot.

An over-long line is **discarded and the connection kept** — `BridgeLink.cs`'s own
disposition in the other direction. Tearing the link down would take the live event
feed with it over one malformed frame, and the lost reply just times out and is
re-requested; everything on this plane is idempotent.

**`LineReader` holds its state in a struct rather than in locals, and that is the
subtle part.** This is polled inside a `tokio::select!`, so the future is dropped
whenever a command wins the race. A `discarding` flag in a local would be lost with
it — and losing it turns the tail of an over-long line into a line of its own, silently.
There is a test for exactly that, and another for an over-long line whose terminator
lands in the very chunk that crosses the cap.

## `GET /assets/sources`

Stage 1 of the import gate, forwarded verbatim like everything else. `respond_assets`
maps `bridge.busy` → **425** and a disabled plane → **403**.

425 deserves a note: on this plane it is not an idempotency collision, it is flow
control, and it is the **ordinary** answer mid-import rather than a rare one. The shard
serves one asset request at a time because its outbound queue is bounded in lines, not
bytes. A caller treating it as an error would abandon a healthy transfer.

403 for the same reason the event plane's gate is a 403: `Bridge.AssetsEnabled` off is
an operator declining to let the website read their client files, not a malformed
request, and 400 would send an administrator hunting a bug in a correct call.

## `PROTOCOL_VERSION` 7 → 8

Paired with `servuo-plugins/overlay.toml` in the linked PR — the installer refuses to
compose a bundle whose halves disagree, so a split bump fails silently at the next
release.

## Also

`docs/link/INTEGRATION.md` still advertised `X-UOLink-Version: 6`; it was already two
versions stale before this change. Fixed in the docs PR.

61 tests pass, `cargo fmt --check` and `cargo clippy -- -D warnings` clean. Verified
against the real shard: `/health` reports protocol 8, `/assets/sources` returns 200 with
`X-UOLink-Version: 8`, and live events kept flowing through the new reader with no
warnings logged.

- [x] AI-assisted — Claude Code (Opus 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-10 08:32:39 -05:00
6c8a247761 Merge pull request 'feat(sidecar): protocol 7 — the Event System's command plane (Phase 16b cutover, 1 of 6)' (#40) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 21s
SonarQube / analysis (push) Successful in 1m26s
Release sidecar / release (push) Failing after 8m39s
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-09 19:54:28 +00:00
f39dfa4f84 Merge pull request 'feat(web): the borrowed planes and the one-shots on the wire (Phase 12b)' (#39) from feature/events-p12b-borrowed-and-oneshots into edge
Some checks failed
PR Checks / rust-gates (pull_request) Failing after -22s
Reviewed-on: #39
2026-09-07 16:23:19 +00:00
d83bb1748c feat(web): the borrowed planes and the one-shots on the wire (Phase 12b)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m8s
The sidecar half of protocol 7 part b. `PROTOCOL_VERSION` stays 7: 12b amends 7
in place rather than bumping again, which is tolerable for the single reason 6
and 7 already are and no other -- nothing is released from `edge`.

The lease family gains a `target` rather than a family of its own. A property
lease, a seasonal toggle and a config key are one protocol with three catalogs,
so there is one deadline, one compare-and-set, one grace window and one set of
counters instead of three of each.

`GET /lease?key=&target=` narrows to one row, and a targeted key needs it.
`Spawner.MaxCount` is one capability over thousands of spawners, so it has no
single `current` and the catalog walk cannot fill one in -- while the website's
`read()` needs exactly one value for exactly one target BEFORE it applies
anything. Naming both answers that.

The frame also always carries `holds`: every lease the shard is actually holding,
whatever key or target it is on. A catalog walk can enumerate the KEYS but never
the holds on a targeted one -- there is no list of spawners to walk -- so without
it a reconcile after an outage would have no way to ask "what are you still
holding?". `inForce()` reads that.

Three new routes. `GET /items` is the shard's own grant allowlist, so the
website's dropdown offers what this shard will actually build. `POST
/items/grant` names a RUN and never a recipient list: the shard has held the
run's participation ledger since protocol 6 part b, keyed by the same character
serials the website's `member_key` holds, so sending a list would put it on the
wire twice with a window in which the two disagree. `POST /world/save` starts a
save; what actually happened rides `world.save.before`/`after`, which have been
on the stream since protocol 2.

Two status mappings are the point of the diff rather than plumbing:

A run with no ledger open is a 404 and a run whose ledger is open and empty is a
200 with `granted: 0`. "You never told me to count" and "nobody came" are
different facts, and only the first is a mistake -- an event nobody attended
still happened, and answering it as a failure would have the module retry against
a ledger that will be just as empty next time.

A save refused for coming too soon is a 429, not the 400 every other refusal on
this plane is. It is the one refusal here that the same request gets past by
waiting, so 429 says exactly that and keeps it out of the module's
permanent-status set -- which is what makes a phase boundary retried rather than
abandoned.

`cargo fmt --check`, `cargo clippy --all-targets -- -D warnings` and `cargo test`
all clean: 51 passed (was 49). The two new tests pin those two mappings.

Also exercised end to end against the real local ServUO 57.4 world driving this
binary's REST -- including that `/world/save` is not eaten by `/world/:run_id`
next door. See servuo-plugins for the walk.

Refs: docs/link/v7.md §11-§13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:07:34 -05:00
5cdd80e694 Merge pull request 'feat(web): the world verbs on the wire (protocol 7, Phase 12a)' (#38) from feature/events-p12a-world-verbs into edge
Reviewed-on: #38
2026-09-07 06:58:22 +00:00
5d909ca0a3 feat(web): the world verbs on the wire (protocol 7, Phase 12a)
Some checks failed
PR Checks / rust-gates (pull_request) Failing after 9s
`POST /world` places, `GET /world/:runId` says what a run still owns, and
`POST /world/:runId/despawn` gives it back. One route family for five
author-facing verbs, because each of them ends in "an object exists and this run
owns it" -- the differences between a boss's multipliers, an oracle's lines and
a gate's destination are fields on one command, not five commands.

`PROTOCOL_VERSION` -> 7. The overlay's `overlay.toml` is bumped in the same
window; 12b amends 7 in place rather than bumping again, so an overlay and a
sidecar both declaring 7 are interchangeable only within one side of that merge
-- tolerable for the same single reason 6 was, and no other: nothing is released
from `edge`.

`world.owned` is a GET, unlike `participation.snapshot`: it carries no
idempotency key and the shard answers it in one pass. A run the shard has no
rows for answers with an EMPTY hand rather than a 404, and the distinction is
load-bearing for reconcile -- "owns nothing" and "never heard of it" are the
same fact once the registry is the only record of ownership, and they stay the
same fact across a restart, because the registry is written by the same world
save as the objects it describes.

Two tests pin what the world verbs depend on from `respond_event`, rather than
trusting that its reason-sniffing keeps covering a kind it predates: a ceiling
refusal is a 400 (permanent -- retrying "you asked for 80 and this shard places
30" gets the same answer forever), the event gate being off is still a 403, and
an empty owned list is a 200.

Refs: docs/link/v7.md, docs/website/EVENTS_PLAN.md Phase 12a

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 01:51:51 -05:00
d38a9e8a75 Merge pull request 'feat(sidecar): carry the event plane (Phase 11b)' (#37) from feature/events-p11b-leases-participation into edge
Reviewed-on: #37
2026-09-05 04:11:11 +00:00
93411966d7 feat(sidecar): carry the event plane (Phase 11b)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m6s
Protocol 6 amended in place, so PROTOCOL_VERSION is unchanged. Six routes and a
fourth responder; no store migration and no new machinery.

`event_call` is `admin_call` without the required `actor`: an event verb's author
is a RUN, which the body carries as `runId`, and demanding a human name for
something no human is doing would have the runner inventing one.

`respond_event` exists for two mappings the generic responder gets wrong. A
drifted lease is a 200 -- the shard was asked to compare and set, it compared,
and it declined to overwrite somebody's deliberate change, which is the mechanism
working -- and deliberately not the 409 the version gate owns, for the same
reason 425 is not. And the event plane being switched off is a 403 rather than a
reason-sniffed 400: it is an operator's deliberate refusal, and a 400 would send
an administrator hunting a bug in a step that is written correctly.

`participation.snapshot` is a POST for a read, because it carries the caller's
idempotency key and the shard may refuse it as a repeat in flight.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 19:31:31 -05:00
d13ad11eb0 Merge pull request 'feat(sidecar): protocol 6 — carry the key, answer bridge.busy (Phase 11a)' (#36) from feature/protocol-v6-idempotency into edge
Reviewed-on: #36
2026-09-04 23:06:43 +00:00
5612fba744 feat(sidecar): protocol 6 — carry the idempotency key, answer bridge.busy (Phase 11a)
Some checks failed
PR Checks / rust-gates (pull_request) Failing after -16s
PROTOCOL_VERSION 5 -> 6, and one behaviour: `bridge.busy` maps to 425 Too Early in
all three responders.

Everything else is free, and that is the point. The key rides in the command body,
which every write endpoint already passes through verbatim; `champ.boss.killed`
lands in `events` and on the feed through the generic forward path with no arm of
its own. No store migration — nothing gains a column.

Worth naming what the dumb-forwarder property means here specifically: the sidecar
makes no idempotency promise of its own. It does not dedupe, does not cache, and
does not know what a key means. The guarantee is the shard's, end to end, which is
the only place it can be.

425 rather than 409 because 409 is already the protocol-version gate's answer, and
the two want opposite dispositions from a client: a version mismatch is a
deployment fault nobody should retry, a busy shard is a retry that will succeed.
Sharing a status would make the difference readable only by inspecting the body,
which is how a retry loop ends up hiding a mismatched deployment. A replayed reply
is an ordinary 200 — `replayed: true` is for the log.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 14:57:08 -05:00
8b9dd0d9e8 Merge pull request 'feat(sidecar): protocol 5 — cutover 2b of 7 (edgemain)' (#35) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 11s
SonarQube / analysis (push) Failing after -37s
Release sidecar / release (push) Successful in 11m55s
Reviewed-on: #35
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-01 13:55:27 +00:00
f41237392d Merge pull request 'feat(sidecar): protocol 5' (#34) from feature/protocol-v5 into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m16s
Reviewed-on: #34
2026-09-01 00:27:05 +00:00
d0c2e7d6e1 feat(sidecar): protocol 5
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m55s
PROTOCOL_VERSION 4 -> 5, and nothing else.

That is the whole change, and it is worth saying why. Protocol 5 adds fields to
house.decay and vendor.listing and one new kind, account.login.result — and the
sidecar needs no code for any of it. Every frame is persisted whole, the board
tables index only the columns they already had, and there is no kind allowlist, so
the new fields ride inside the stored JSON and the new kind lands in `events` like
any other.

No store migration this time, unlike v4. v4 needed one because it added a column to
a board table that already existed; nothing here does. A bump that touches one
constant is the EXPECTED cost of an additive protocol version in a dumb forwarder —
the sidecar defines no schema for a frame's contents, so it needs no change when
they grow. v4 was the exception.

The doc comment records the three enrichments and why they were bumped together: a
protocol bump costs a sidecar release, a republished bundle and an operator update
on every shard, so a field left out costs a whole second round of that rather than a
follow-up commit.

Verified against the real shard: GET /health reports "protocol": 5, and all three
enrichments arrived through the generic forward path — the decay schedule (with
estimatedCollapse present only on the IDOC frame), the vendor fee block, and both
outcomes of account.login.result.

cargo fmt --check clean, clippy -D warnings clean, 39 tests passing.

Docs: RunicGateway/docs link/v5.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 19:19:48 -05:00
4b8ea768b6 Merge pull request 'ci(release): show the error body, retry the POST, and sweep for orphan tags' (#33) from ci/release-post-retry-and-error-body into main
All checks were successful
sync-project-tree / sync (push) Successful in 9s
Release sidecar / release (push) Successful in -55s
SonarQube / analysis (push) Successful in 51s
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 19:42:20 +00:00
6fb063818a ci(release): show the error body, retry the POST, and sweep for orphan tags
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m42s
This file is the ancestor of installer's release.yml, and installer#22's
release run found two gaps in it the hard way: the run built every artifact,
pushed its tag, then took a 500 from POST /releases one second later and exited
22, leaving the tag orphaned with no binaries published.

link has not hit that, but it has the same two gaps verbatim.

`curl -sSf` prints no response body on an error status, so the only thing such
a failure leaves in the log is "curl: (22) ... error: 500" and the cause has to
be inferred from timestamps. Every call in the release step now captures the
body and prints it on failure, including the asset uploads.

And nothing retried, so a transient 5xx becomes a permanent orphan. The POST
now retries five times with a 5/10/15/20s backoff. 4xx is deliberately not
retried: a bad token or a malformed body will not improve by being sent again,
and retrying would turn a clear failure into a slow one.

The asset uploads get the same treatment, because a release whose SHA256SUMS
does not cover every binary it advertises is worse than no release -- that file
is the trust anchor for an unsigned download.

The third gap is the one worth reading. The orphan-tag recovery in the plan
step is VERSION-SCOPED: it computes VERSION from the newest tag plus the bump,
then only checks refs/tags/v${VERSION}. That recovers an orphan on the very
next run and is useless afterwards, because once any releasable commit lands
the next run computes a NEW version and never looks at the old tag again.

servuo-plugins v0.1.0 proves it, and the proof is pointed: the commit that
ADDED that recovery was itself typed "fix(release): ... recover the orphaned
v0.1.0 tag", so it bumped to v0.1.1 and the run that introduced the recovery
stepped straight past the tag it was written to rescue. That tag is orphaned to
this day.

So the plan step now sweeps every v* tag and warns about any without a release.
Deliberately warns rather than recovers: publishing an old version would mean
building today's tree and shipping it under a tag whose tree it is not, which
is worse than the inconsistency it fixes. It also never fails the run -- a
sweep that can break a good release is a sweep someone will delete.

Verified by extracting both steps from the YAML and running them: bash -n
clean, the YAML parses, no empty template token in either step, the retry loop
exercised against a stubbed curl across seven cases (first-try success,
500-then-success, two 500s then success, five 500s giving up, 403 and 404
aborting without retrying, and a 000 network failure retried), and the sweep
run against the real repositories -- link clean, servuo-plugins reporting
v0.1.0, installer clean.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no binary, so a release here would be an empty one. That is
the same rule the fix commit above tripped over.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 12:40:42 -05:00
4 changed files with 1464 additions and 32 deletions

View File

@@ -149,6 +149,39 @@ jobs:
fi fi
fi fi
# ── Orphan sweep ────────────────────────────────────────────────
#
# The check above is VERSION-SCOPED: it only ever asks about the one
# version this run computed. That is enough to recover an orphan on
# the very next run, and useless afterwards — once any releasable
# commit lands, the next run computes a NEW version, never looks at
# the old tag again, and the orphan becomes permanent and silent.
#
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
# commit that ADDED the recovery above was itself typed
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
# v0.1.1 — and the run that introduced the recovery stepped straight
# past the tag it was written to rescue. That tag is still orphaned.
#
# So every v* tag is checked, and anything missing a release is
# WARNED about. Deliberately not recovered: publishing an old version
# would mean building today's tree and shipping it under a tag whose
# tree it is not, which is worse than the inconsistency it fixes.
# A human decides whether to recover or drop it.
#
# Never fails the run. A sweep that can break a good release is a
# sweep someone will delete.
ORPHANS=""
for T in $(git tag -l 'v*' --sort=-v:refname); do
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
done
if [ -n "${ORPHANS}" ]; then
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
fi
# Changelog range. A recovery run has nothing after the tag, so # Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty # summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag. # list: the range that produced it, i.e. previous-tag..this-tag.
@@ -342,18 +375,75 @@ jobs:
# corrupt the Authorization header. # corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')" CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \ PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
# This POST is the step that orphaned tag v0.1.1 (run 75): it landed one
# second after the tag push and Gitea answered 500, having not finished
# processing the pushed tag. Re-running the workflow published the same
# four assets untouched, so the failure was a race, not a bad request.
#
# Two things went wrong there, and both are fixed here.
#
# 1. `curl -sSf` prints NO response body on an error status, so all the
# log carried was "curl: (22) ... error: 500" and the cause had to be
# inferred from timestamps. Capture the body and print it.
# 2. Nothing retried, so a transient 5xx became a permanent orphan tag.
# The plan step CAN recover one, but only on a run that reaches it --
# and a later push with no releasable commits stands down before it
# gets there, so in practice the tag sits until a human notices.
#
# 4xx is deliberately NOT retried: a bad token or a malformed body does
# not improve by being sent again, and retrying only turns a clear
# failure into a slow one.
REL_ID=""
for attempt in 1 2 3 4 5; do
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \ -H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \ -d "${PAYLOAD}" || echo 000)"
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')" if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
REL_ID="$(jq -r '.id' /tmp/rel.json)"
break
fi
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
echo "--- response body ---"
cat /tmp/rel.json || true
echo
echo "---------------------"
case "$HTTP" in
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
esac
if [ "$attempt" = 5 ]; then
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
exit 1
fi
sleep $(( attempt * 5 ))
done
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
echo "::error::Release created but no id came back; refusing to upload assets blind."
exit 1
fi
echo "Created release ${TAG} (id=${REL_ID})" echo "Created release ${TAG} (id=${REL_ID})"
for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \ # Same treatment. An upload that fails quietly leaves a release whose
# SHA256SUMS does not cover every binary it advertises, which is worse
# than no release at all -- that file IS the trust anchor.
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \ -H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null -F "attachment=@dist/${f}" || echo 000)"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "::error::uploading ${f} returned HTTP ${HTTP}"
cat /tmp/asset.json || true
exit 1
fi
echo " uploaded ${f}" echo " uploaded ${f}"
done done

View File

@@ -52,7 +52,79 @@ use tracing_subscriber::EnvFilter;
/// kinds are new, `GET /guilds` grows a `roster` key, and nothing existing changed shape. This is the /// kinds are new, `GET /guilds` grows a `roster` key, and nothing existing changed shape. This is the
/// first bump that also needed a **store migration** (`guilds.members`), because it is the first to /// first bump that also needed a **store migration** (`guilds.members`), because it is the first to
/// add a column to a table that already exists rather than a whole new table; see `store::migrate`. /// add a column to a table that already exists rather than a whole new table; see `store::migrate`.
pub const PROTOCOL_VERSION: u32 = 4; ///
/// v5 (Protocol 5): three enrichments that are additive in the same way again, bumped together
/// rather than one at a time because a protocol bump is not cheap here — it costs a sidecar
/// release, a republished bundle and an operator update on every shard, so a field left out costs
/// a whole second round of that rather than a follow-up commit. They are:
///
/// * `house.decay` gains `ownerName` and a decay SCHEDULE — `nextStage`, `decayPeriodSec`,
/// `dynamicDecay`, and `estimatedCollapse` only where it is exactly knowable (at IDOC under
/// dynamic decay; at any stage under static decay, which has no randomness to wait out).
/// * `vendor.listing` gains `ownerAcct` — without which the frame names an owner nobody can
/// resolve to a person — and a `fees` object carrying the charge, the funds, the pay interval
/// and the resolved `dismissalAt`.
/// * `account.login.result` is a NEW kind: the verdict of a login, which the pre-existing
/// `account.login.attempt` structurally cannot carry (its EventSink fires before the auth
/// decision is made).
///
/// **No store migration this time**, unlike v4. Every frame is persisted whole and the board tables
/// index only the columns they already had, so the new fields ride inside the stored JSON and the
/// new kind lands in `events` like any other. That is the dumb-forwarder property doing its job:
/// the sidecar defines no schema for a frame's contents and so needs no change when they grow.
///
/// v6 (Protocol 6): the first bump that is about a GUARANTEE rather than about data, and the first
/// the sidecar mostly gets for free. Two things:
///
/// * **`idempotencyKey` on inbound commands.** A command that carries one is executed by the shard
/// at most once; a repeat is answered with the original reply rather than re-run. That is what
/// makes a world-writing verb retryable at all — until now a lost acknowledgement was
/// indistinguishable from a command that never applied, so the website had to declare every
/// write un-retryable and accept losing one rather than risk doubling it. The sidecar's part is
/// to CARRY the key (it rides in the command body, which every write endpoint already passes
/// through verbatim) and to understand the one new answer the shard can now give: `bridge.busy`,
/// meaning a command under that key is still in flight. See `web::respond`.
/// * **`champ.boss.killed` is a new kind**: a champion's defeat, with the damage table only the
/// shard ever sees. It was previously inferable from `champ.update` going `bossUp` true then
/// false alongside a nearby `mob.killed`, which is fragile and says nothing about who did the
/// work. It lands in `events` and on the feed like any other kind, with no code here at all —
/// the dumb-forwarder property again.
///
/// **No store migration.** Nothing gains a column; the new kind is persisted whole like every other.
///
/// # Protocol 8 — the Asset Bridge (docs/link/v8.md)
///
/// The shard starts sending the operator's own **client assets** over this link: the cliloc string
/// table, creature and item art, player models. The point is that an operator stops having to run
/// a GUI converter on a desktop to make their site render a bestiary, and the shard is the only
/// host that already has the client files — a ServUO server cannot boot without them.
///
/// Phase 1 is the transport, and the sidecar's share of it is three things:
///
/// * **A new command family, `assets.*`, forwarded verbatim** like every other. The first of them
/// is `assets.sources` — stage 1 of the import gate: what the client files currently are, and
/// what version of the shard's extractor would read them. No pixels cross on this call.
/// * **An inbound line cap** — [`shard::MAX_INBOUND_LINE_BYTES`]. This is the one change that is
/// not additive. `read_line` had no bound at all, which was survivable while the shard had no
/// reason to send a large line; protocol 8 gives it one deliberately, and an unbounded read
/// facing a component that now sends megabytes is a memory-exhaustion shape we would be
/// inventing ourselves.
/// * **Nothing else.** Assets ride the request/reply path, so `rpc::try_route` consumes them
/// before `app.rs` can persist them to the store and fan them out to every WebSocket
/// subscriber — which is what keeps a 512 KiB reply from being written to SQLite and broadcast
/// 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.
///
/// 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.
pub const PROTOCOL_VERSION: u32 = 8;
// Not `#[tokio::main]`: on Windows the SCM dispatcher takes over this thread and starts the runtime // Not `#[tokio::main]`: on Windows the SCM dispatcher takes over this thread and starts the runtime
// itself, on its own thread, once the service actually begins. The runtime is built by whichever // itself, on its own thread, once the service actually begins. The runtime is built by whichever

View File

@@ -7,15 +7,130 @@
//! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We //! Framing is newline-delimited JSON, bidirectional: the shard sends events, we send commands. We
//! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its //! accept one shard connection at a time and re-accept when it drops (the shard reconnects on its
//! own, with a bounded backoff). //! own, with a bounded backoff).
//!
//! Inbound lines are **capped** (see [`MAX_INBOUND_LINE_BYTES`]). Until protocol 8 they were not:
//! `read_line` will buffer a line of any length, which was survivable only because the shard had
//! never had a reason to send a large one. The Asset Bridge gives it one, so the gap had to close
//! before it became a memory-exhaustion shape we invented ourselves.
use std::sync::Arc; use std::sync::Arc;
use serde_json::Value; use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{mpsc, Mutex};
use tracing::{info, warn}; use tracing::{info, warn};
/// The longest line the sidecar will accept from the shard, in bytes.
///
/// Set above the largest legal batch rather than at it: the shard cuts a batch when the next item
/// would take it past `Bridge.AssetBatchBytes` (512 KiB), and always admits the first item of a
/// page even when that item alone is bigger than the budget — so one page can legitimately
/// overshoot by one item. Doubling the budget to get this cap is what makes that overshoot safe
/// instead of a dropped reply.
///
/// Over-long lines are **discarded, not buffered**, and the connection stays up. That is the same
/// disposition `BridgeLink.cs` has always had for its own 1 MiB inbound cap in the other
/// direction, and it is the right one here: a single malformed frame is not a reason to tear down
/// a link that live events are flowing over. The dropped reply simply times out and is
/// re-requested, which is safe because everything on the asset plane is idempotent.
pub const MAX_INBOUND_LINE_BYTES: usize = 1024 * 1024;
/// What one read off the shard socket produced.
#[derive(Debug)]
enum Line {
/// A complete line, within the cap.
Complete(String),
/// A line that ran past the cap. Carries how many bytes were thrown away, for the log.
TooLong(usize),
/// The shard closed the connection.
Eof,
}
/// A cancel-safe, capped, newline-delimited reader.
///
/// Every piece of state that must survive a partial read lives here rather than in a local,
/// because this is polled inside a `tokio::select!`: the loop below drops the future whenever a
/// command wins the race, and a `discarding` flag or a half-filled buffer held in a local would be
/// lost with it. Losing the buffer corrupts the *next* line; losing `discarding` turns the tail of
/// an over-long line into a line of its own. Both are silent.
///
/// The only await point is `fill_buf`, and nothing is consumed until after it returns, so a
/// cancellation between the two can lose at most the wakeup.
#[derive(Default)]
struct LineReader {
buf: Vec<u8>,
discarding: bool,
discarded: usize,
}
impl LineReader {
async fn next<R: AsyncBufRead + Unpin>(&mut self, reader: &mut R) -> std::io::Result<Line> {
loop {
let consumed;
let outcome;
{
let available = reader.fill_buf().await?;
if available.is_empty() {
return Ok(Line::Eof);
}
match available.iter().position(|&b| b == b'\n') {
Some(at) => {
consumed = at + 1;
if self.discarding {
// The tail of a line we already gave up on. Swallow it, terminator
// included, and report the size once.
self.discarded += at;
let total = self.discarded;
self.discarding = false;
self.discarded = 0;
outcome = Some(Line::TooLong(total));
} else if self.buf.len() + at > MAX_INBOUND_LINE_BYTES {
// The cap is reached only now, on the chunk that also holds the
// terminator — so there is nothing left to discard.
let total = self.buf.len() + at;
self.buf.clear();
outcome = Some(Line::TooLong(total));
} else {
self.buf.extend_from_slice(&available[..at]);
let line = String::from_utf8_lossy(&self.buf).into_owned();
self.buf.clear();
outcome = Some(Line::Complete(line));
}
}
None => {
consumed = available.len();
if self.discarding {
self.discarded += consumed;
} else if self.buf.len() + consumed > MAX_INBOUND_LINE_BYTES {
// Refuse rather than buffer: this is the whole point of the cap.
// Everything up to the next newline is now dropped on the floor.
self.discarded = self.buf.len() + consumed;
self.buf.clear();
self.discarding = true;
} else {
self.buf.extend_from_slice(available);
}
outcome = None;
}
}
}
reader.consume(consumed);
if let Some(line) = outcome {
return Ok(line);
}
}
}
}
/// An event line received from the shard, parsed. `kind` is lifted out for routing. /// An event line received from the shard, parsed. `kind` is lifted out for routing.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ShardEvent { pub struct ShardEvent {
@@ -112,16 +227,25 @@ async fn handle_connection(
handle.set(Some(cmd_tx)).await; handle.set(Some(cmd_tx)).await;
let mut reader = BufReader::new(read_half); let mut reader = BufReader::new(read_half);
let mut line = String::new(); let mut lines = LineReader::default();
loop { loop {
tokio::select! { tokio::select! {
// Inbound: a line from the shard. // Inbound: a line from the shard.
result = reader.read_line(&mut line) => { result = lines.next(&mut reader) => {
let n = result?; match result? {
if n == 0 { Line::Eof => return Ok(()), // clean EOF: shard closed
return Ok(()); // clean EOF: shard closed Line::TooLong(bytes) => {
// Deliberately not a disconnect. See MAX_INBOUND_LINE_BYTES: a reply lost
// this way times out on the caller's side and is re-requested, and tearing
// the link down would take the live event feed with it.
warn!(
bytes,
cap = MAX_INBOUND_LINE_BYTES,
"inbound line over the cap; discarded"
);
} }
Line::Complete(line) => {
let trimmed = line.trim_end(); let trimmed = line.trim_end();
if !trimmed.is_empty() { if !trimmed.is_empty() {
match serde_json::from_str::<Value>(trimmed) { match serde_json::from_str::<Value>(trimmed) {
@@ -136,7 +260,8 @@ async fn handle_connection(
Err(e) => warn!(error = %e, line = %trimmed, "unparseable event"), Err(e) => warn!(error = %e, line = %trimmed, "unparseable event"),
} }
} }
line.clear(); }
}
} }
// Outbound: a command to write to the shard. // Outbound: a command to write to the shard.
cmd = cmd_rx.recv() => { cmd = cmd_rx.recv() => {
@@ -152,3 +277,108 @@ async fn handle_connection(
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// Drives `LineReader` over a byte slice, returning every outcome up to EOF.
async fn read_all(input: &[u8]) -> Vec<Line> {
let mut reader = BufReader::with_capacity(64, input);
let mut lines = LineReader::default();
let mut out = Vec::new();
loop {
match lines.next(&mut reader).await.unwrap() {
Line::Eof => break,
other => out.push(other),
}
}
out
}
fn complete(lines: &[Line]) -> Vec<&str> {
lines
.iter()
.filter_map(|l| match l {
Line::Complete(s) => Some(s.as_str()),
_ => None,
})
.collect()
}
#[tokio::test]
async fn splits_on_newlines() {
let lines = read_all(b"{\"a\":1}\n{\"b\":2}\n").await;
assert_eq!(complete(&lines), vec!["{\"a\":1}", "{\"b\":2}"]);
}
/// The reader's buffer is 64 bytes here, so every one of these lines spans several
/// `fill_buf` chunks. Reassembly across chunks is the thing `read_line` did for us.
#[tokio::test]
async fn reassembles_across_chunks() {
let long = "x".repeat(500);
let input = format!("{}\n{}\n", long, long);
let lines = read_all(input.as_bytes()).await;
assert_eq!(complete(&lines), vec![long.as_str(), long.as_str()]);
}
/// The cap itself. The over-long line must be reported and thrown away, and — the part that
/// actually matters — the line *after* it must still arrive intact. A reader that lost its
/// `discarding` flag would emit the tail of the oversized line as a line of its own.
#[tokio::test]
async fn refuses_an_over_long_line_and_recovers() {
let mut input = Vec::new();
input.extend_from_slice(&b"a".repeat(MAX_INBOUND_LINE_BYTES + 10));
input.push(b'\n');
input.extend_from_slice(b"{\"kind\":\"pong\"}\n");
let lines = read_all(&input).await;
assert_eq!(lines.len(), 2);
assert!(
matches!(lines[0], Line::TooLong(n) if n >= MAX_INBOUND_LINE_BYTES),
"expected TooLong, got {:?}",
lines[0]
);
assert_eq!(complete(&lines), vec!["{\"kind\":\"pong\"}"]);
}
/// A line of exactly the cap is legal; one byte more is not. Checking both sides is what says
/// the comparison is `>` rather than `>=`, which would silently cost a byte of the budget.
#[tokio::test]
async fn the_cap_is_inclusive() {
let at_cap = "b".repeat(MAX_INBOUND_LINE_BYTES);
let lines = read_all(format!("{}\n", at_cap).as_bytes()).await;
assert_eq!(complete(&lines).len(), 1);
let over = "b".repeat(MAX_INBOUND_LINE_BYTES + 1);
let lines = read_all(format!("{}\n", over).as_bytes()).await;
assert!(complete(&lines).is_empty());
assert!(matches!(lines[0], Line::TooLong(_)));
}
/// An over-long line whose terminator lands in the very chunk that crosses the cap: the
/// reader must not leave itself in `discarding` and eat the next line as well.
#[tokio::test]
async fn over_long_line_terminating_in_the_crossing_chunk() {
let mut input = Vec::new();
input.extend_from_slice(&b"c".repeat(MAX_INBOUND_LINE_BYTES + 1));
input.extend_from_slice(b"\n{\"kind\":\"pong\"}\n");
let lines = read_all(&input).await;
assert!(matches!(lines[0], Line::TooLong(_)));
assert_eq!(complete(&lines), vec!["{\"kind\":\"pong\"}"]);
}
/// A partial line at EOF is dropped rather than delivered half-parsed. The shard reconnects
/// and re-sends; half a JSON object is not something to hand to the event fan-out.
#[tokio::test]
async fn trailing_partial_line_at_eof_is_dropped() {
let lines = read_all(b"{\"a\":1}\n{\"b\":").await;
assert_eq!(complete(&lines), vec!["{\"a\":1}"]);
}
}

File diff suppressed because it is too large Load Diff