fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179)
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:
@@ -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 ─────────────────────────────────
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user