From 397e0b92e93284a3a97f80a3ccdd1581897d712d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 03:42:42 -0500 Subject: [PATCH 1/2] fix(rust): an update on a running server reloads the plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin was placed with write_atomic: remove the old file, rename a .tmp over it. Oxide and Carbon watch the plugins directory and reload on a change; both saw the remove as a delete and UNLOADED the bridge, and ignored the rename, so the new file was never loaded. Every `update` (or re-install) on a running server took the bridge down until the next boot, silently — the phase 18 walk found it on all three instances and both frameworks, after the move to v0.1.1. The plugin is now overwritten in place, which is what an operator's `cp` does and what both frameworks reload on. write_atomic stays for the record and the configs, where atomicity is the point. `update` on the same bundle also no longer says "nothing moved" after it has just put back a hand-edited plugin — the remedy `doctor` names. Walked: hand-edit, then `update`, on a running Oxide and a running Carbon server: replaced, reloaded, reconnected, doctor clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- src/rustgame/install.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/rustgame/install.rs b/src/rustgame/install.rs index 0c01421..121ad0b 100644 --- a/src/rustgame/install.rs +++ b/src/rustgame/install.rs @@ -324,7 +324,17 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { let before = prior.as_ref().map(|p| p.bundle.clone()); match before { Some(b) if b.tag == bundle.bundle => { - println!("\nAlready on bundle {} — nothing moved.", bundle.bundle) + // The same bundle can still have written something: a plugin edited by hand is + // put back, which is what `doctor` tells an operator to run `update` for. + let restored = planned.iter().filter(|p| p.plugin_action.writes()).count(); + if restored == 0 && !binary_action.writes() { + println!("\nAlready on bundle {} — nothing moved.", bundle.bundle) + } else { + println!( + "\nAlready on bundle {} — put back what no longer matched it (see above).", + bundle.bundle + ) + } } Some(b) => { println!( @@ -656,7 +666,16 @@ fn deploy_instance( if plan.plugin_action.writes() { std::fs::create_dir_all(plan.server.plugins_dir()) .with_context(|| format!("cannot create {}", plan.server.plugins_dir().display()))?; - write_atomic(&plugin_path, &released.source)?; + // Overwritten in place, NOT `write_atomic`. Oxide and Carbon watch the plugins directory + // and reload on a CHANGE; `write_atomic` removes the old file and renames a `.tmp` over it, + // which both frameworks see as a delete — they unload the bridge — and a rename they + // ignore, so the new file is never loaded. On a running server that was an `update` that + // silently took the bridge down until the next boot (the phase 18 walk, on all three + // instances and both frameworks). A write in place is what an operator's `cp` does, and + // it reloads. A crash mid-write leaves a file that fails to compile; `doctor` reports it + // as not the deployed file and `update` writes it again. + std::fs::write(&plugin_path, &released.source) + .with_context(|| format!("cannot write {}", plugin_path.display()))?; ui::ok(&format!( "plugin {} {}", if plan.plugin_action == BinaryAction::Replace { -- 2.49.1 From e5611a01ebbae6af218672014de88bfe1d9c5dd6 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Sat, 26 Sep 2026 03:45:27 -0500 Subject: [PATCH 2/2] fix(service): say why the service account stays, truthfully for Rust too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uninstall --game rust --server-id beta` printed "left the runicgateway account alone — this installer did not create it" on a host where this installer had created it, for alpha. A Rust instance's service never owns the shared account (the last instance's removal decides), so the reason now reads "it is not this service's to remove" — true for both games. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- src/service.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/service.rs b/src/service.rs index f3c0913..4b6071a 100644 --- a/src/service.rs +++ b/src/service.rs @@ -973,8 +973,11 @@ fn remove_platform(record: &ServiceRecord) -> Removal { )), } } else if let Some(user) = record.user.as_deref() { + // Not "this installer did not create it": a Rust instance's service never owns the account + // even when this installer made it, because every instance shares it — the last instance's + // removal decides (`rustgame::uninstall`). Said that way, the line is true for both games. out.done.push(format!( - "left the {user} account alone — this installer did not create it" + "left the {user} account alone — it is not this service's to remove" )); } out -- 2.49.1