fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179)
Some checks failed
PR Checks / server-tests (pull_request) Failing after 16s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 37s

The plugin now answers a save once the files are written, with pending and
a writeId, and reports the reload later as a config.outcome event. The save
is recorded as reloading with a settle_by of two plugin ceilings plus slack
on the database's clock; ingest settles the row by (server, writeId), only
while it is still reloading, so a replay moves nothing and a late outcome
still lands. A row past settle_by reads as lost.

GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page
polls it every two seconds, holds the Save button while it waits, and says
whether a rolled-back plugin came back on its old file. config.outcome is
a staff kind: it carries the server's log tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 17:16:45 -05:00
parent 36eae7ffa8
commit 654a24585d
13 changed files with 515 additions and 37 deletions

View File

@@ -220,6 +220,12 @@ export const adminConfig = {
writes: (serverId, limit = null) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`),
// One write, polled while its reload settles (protocol 13). A save that
// reloads a plugin answers before the reload has finished — behind a cold
// compile that can be many seconds — and this is where the outcome lands.
write: (serverId, id) =>
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes/${encodeURIComponent(id)}`),
}
// ── the admin.users.detail extension slot ─────────────────────────────────

View File

@@ -138,20 +138,59 @@ function Field({ field, value, onChange, revealed, onReveal }) {
)
}
/** How often a save that is still reloading asks how it went. */
const POLL_MS = 2000
/**
* A settled write row as the report panel reads it.
*
* The row is this module's audit record, not the plugin's frame, so it carries
* no per-file `rewritten` — the file is re-read after either way, which is what
* the panel's note about rewriting was for.
*/
function reportFromWrite(write) {
return {
pending: write.outcome === 'reloading',
lost: write.outcome === 'lost',
reload: write.reloadTarget,
reloaded: Boolean(write.reloaded),
rolledBack: write.outcome === 'rolled-back',
restored: write.restored,
reason: write.detail,
log: write.log,
files: [],
}
}
/** The sentence at the top of the panel. */
function headline(report) {
if (report.pending) {
return `Saved. Reloading ${report.reload || 'the plugin'}… a first compile after a quiet spell can take a while.`
}
if (report.lost) {
return 'The server never said how the reload went — the bridge may have been reloaded, or the link dropped. Re-read the file to see what is on disk.'
}
if (report.rolledBack) {
return report.restored === false
? 'The plugin did not come back, so the old file was put back — and it did not come back on the old file either. It is down: check the server console.'
: 'The plugin did not come back, so the old file was put back automatically.'
}
return report.reloaded ? 'Saved, and the plugin reloaded.' : `Saved. ${report.reason || 'Nothing was reloaded.'}`
}
/** What the game said happened. The rollback case is the one worth reading. */
function Report({ report }) {
if (!report) return null
const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)'
const tone = report.rolledBack || report.lost ? '#e05a5a' : 'var(--ink)'
return (
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
{report.rolledBack
? 'The plugin did not come back, so the old file was put back automatically.'
: report.reloaded
? 'Saved, and the plugin reloaded.'
: `Saved. ${report.reason || 'Nothing was reloaded.'}`}
{headline(report)}
</p>
{report.rolledBack && report.reason && (
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
@@ -196,6 +235,8 @@ export default function ModConfig() {
const [error, setError] = useState('')
const [report, setReport] = useState(null)
const [fileNonce, setFileNonce] = useState(0)
// The write a save left reloading, polled until it settles (protocol 13, D179).
const [watching, setWatching] = useState(null)
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
@@ -243,8 +284,48 @@ export default function ModConfig() {
useEffect(() => {
setPath('')
setReport(null)
setWatching(null)
}, [serverId])
// A save that reloads a plugin comes back before the reload has finished.
// Ask how it went every couple of seconds until the row leaves `reloading` —
// the server calls it `lost` once twice the plugin's ceiling has passed, so
// this always ends — then re-read the file, because a reload rewrites it and a
// rollback puts the old one back.
useEffect(() => {
if (!watching) return undefined
let stopped = false
let handle = null
const poll = async () => {
try {
const { write } = await api.adminConfig.write(watching.serverId, watching.id)
if (stopped) return
setReport(reportFromWrite(write))
if (write.outcome !== 'reloading') {
setWatching(null)
setFileNonce((n) => n + 1)
return
}
} catch {
// One failed poll is a blip, not an answer; the next one asks again.
if (stopped) return
}
handle = setTimeout(poll, POLL_MS)
}
handle = setTimeout(poll, POLL_MS)
return () => {
stopped = true
clearTimeout(handle)
}
}, [watching])
if (serverError) return <ErrorState error={serverError} />
if (!servers) return <Loading />
@@ -277,7 +358,13 @@ export default function ModConfig() {
const answer = await api.adminConfig.save(serverId, body)
setReport(answer.report || null)
if (answer.pending && answer.write && answer.write.id) {
setReport({ ...(answer.report || {}), pending: true, reload: reload || null, files: [] })
setWatching({ serverId, id: answer.write.id })
} else {
setReport(answer.report || null)
}
if (!answer.changed) setError('Nothing changed, so nothing was written.')
// Re-read either way: a successful reload usually rewrites the file with
@@ -483,10 +570,10 @@ export default function ModConfig() {
<button
type="button"
className="btn btn-primary"
disabled={busy || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
disabled={busy || Boolean(watching) || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
onClick={save}
>
{busy ? 'Saving…' : 'Save and reload'}
{busy ? 'Saving…' : watching ? 'Reloading…' : 'Save and reload'}
</button>
</div>