Each locks <home>/run/<role>.lock in main (gatewayd after its secret, so nothing is made before the secret is read). brokerd's old second-instance test in serve.rs expected the audit lock's message, so serve.rs has a new version; task 01 now copies serve.rs-task01. Checks: given tests fail at ref 12 as the task says (2 each in brokerd, memoryd, gatewayd; serve.rs passes); the task's quoted edits applied to ref 12 reproduce ref 13's tree exactly; replay gate ok with 1118 tests (1113 - 1 + 6), /tmp unchanged; 14 mutations, all caught; the six instance test files stressed 25 runs under 16 busy loops and 20 under 32, no failure. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
12 KiB
M5b task 13: brokerd, memoryd and gatewayd take the instance lock too
Branch: m5b (run git switch m5b; git status --short must be empty, otherwise stop)
Commit subject: brokerd, memoryd, gatewayd: a lock file keeps each to one instance
Goal
Task 12 wrote bxcore::instance, the lock that keeps a daemon to one instance (M5b spec,
section 9a), and made loopd and inferproxy take it. This task makes the other three daemons
take it the same way: before they touch their sockets, each locks <home>/run/<role>.lock and
holds the lock for the life of the process. A second copy against the same home exits 1 with one
line and touches nothing else:
brokerd: another brokerd is running (it holds /var/lib/boxmaker/run/brokerd.lock); see docs/runbook.md#already-running
memoryd: another memoryd is running (it holds /var/lib/boxmaker/run/memoryd.lock); see docs/runbook.md#already-running
gatewayd: another gatewayd is running (it holds /var/lib/boxmaker/run/gatewayd.lock); see docs/runbook.md#already-running
Any other error on the lock file stops the daemon too, with the line
<role>: cannot take the lock <lock file>: <error>; see docs/runbook.md#already-running. Both
lines come from LockError::line; the runbook entry already-running is there since task 12, and
covers all five daemons.
Files
- Copy, from
docs/plans/M5b/files/(the same path under it):- new tests:
crates/brokerd/tests/instance.rs,crates/memoryd/tests/instance.rs,crates/gatewayd/tests/instance.rs - a new version of
crates/brokerd/tests/serve.rs(it replaces the file in the tree). Do not copyserve.rs-task01: it is the older version.
- new tests:
- Modify:
crates/brokerd/src/main.rs(B1, B2),crates/memoryd/src/main.rs(D1, D2),crates/gatewayd/src/main.rs(G1 to G3),docs/implementer-log.md. - Do not touch:
crates/bxcore(instanceis done), any other source file, anyCargo.toml, any other test,docs/runbook.md.
Facts you need
-
What
bxcore::instancegives (task 12; do not change it):Name What it is instance::lockpub fn lock(path: &Path) -> Result<InstanceLock, LockError>: creates the file (0600) and a missing directory (0700), and tries the lock onceInstanceLockthe held lock; the lock is released when it is dropped or the process ends LockErrorHeld(another instance holds it) orIo(std::io::Error)LockError::linee.line("brokerd", &lock_path) -> String: the whole line to print, pointer included -
Keep the lock alive. Each daemon binds the
InstanceLockto a name,_instance, inmain(orserveforgatewayd), the function that serves until the process ends.let _ = …, or amatcharmOk(_) => {}, would drop it, and the lock with it, at once. The edits show the form. -
Where each daemon takes it, and why there:
brokerd(mainincrates/brokerd/src/main.rs): right after the config loads, beforeserve::start, which opens the audit log and binds both sockets. The home iscfg.paths.home.memoryd(mainincrates/memoryd/src/main.rs): right after the config loads, beforeserve::start, which createsmemory/and the index and binds three sockets. The home isconfig.paths.home.gatewayd(serveincrates/gatewayd/src/main.rs): after the secret is loaded, before the state directory is made and beforerun, which bindsgateway.sockand then connects to Mattermost. Not earlier: the testa_missing_secret_stops_at_startincrates/gatewayd/tests/main.rschecks that nothing under the home is made before the secret is read, and the lock creates<home>/run. The home isconfig.paths.home.
-
brokerd's audit lock stays.brokerdhas held a lock on<home>/audit/.locksince M3a, and a secondbrokerdused to stop there, withbrokerd is already runningand the pointerbrokerd-already-running. It stays: it guards the log itself, and its own test (a_second_writer_is_refusedincrates/brokerd/tests/audit.rs) still passes. But the instance lock now comes first, so a secondbrokerdstops with the new line instead. That is whyserve.rshas a new version: its testa_second_brokerd_on_the_same_home_refuses_to_startexpected the old message, and it is gone;instance.rschecks the secondbrokerdnow. Nothing else inserve.rschanges. -
The given tests start each daemon as a process, as
brokerd'sserve.rsdoes. Thegatewaydone runs it against the fake Mattermost oftests/support/fake_mm.rs, with its token in the environment (GW_TEST_TOKEN), and checks that it answers ongateway.sockby asking it to post an approval, asbrokerdwould. Every wait has a deadline and every process is killed by a guard. Before this task, thememorydandgatewaydtests that expect a second instance to be refused wait out their deadline (the second copy starts and serves), so each of those files takes about 10 s to fail. That is this, not a hang. -
The calls the edits make:
instance::lockandLockError::linefrom the table above;Path::joinfor the lock file's path;eprintln!andExitCode::from(1)as the lines around them already do.
The edits, exactly
Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file.
crates/brokerd/src/main.rs
B1. The line
use brokerd::serve::{self, ServeError};
becomes
use brokerd::serve::{self, ServeError};
use bxcore::instance;
B2. In main, the line
// Keep the socket paths for the serving notice, before `cfg` is moved into `start`.
becomes
// One brokerd per home (M5b spec, section 9a). The lock comes before anything is made, the
// audit log's own lock included, so a second copy never takes the sockets of the one that
// runs. Held until the process ends.
let lock_path = cfg.paths.home.join("run/brokerd.lock");
let _instance = match instance::lock(&lock_path) {
Ok(held) => held,
Err(e) => {
eprintln!("{}", e.line("brokerd", &lock_path));
return ExitCode::from(1);
}
};
// Keep the socket paths for the serving notice, before `cfg` is moved into `start`.
crates/memoryd/src/main.rs
D1. The line
use memoryd::config::Config;
becomes
use bxcore::instance;
use memoryd::config::Config;
D2. In main, the line
let log: serve::Log = Arc::new(|line: &str| eprintln!("{line}"));
becomes
// One memoryd per home (M5b spec, section 9a). The lock comes before anything under the home
// is made or bound, so a second copy never takes the sockets of the one that runs. Held until
// the process ends.
let lock_path = config.paths.home.join("run/memoryd.lock");
let _instance = match instance::lock(&lock_path) {
Ok(held) => held,
Err(e) => {
eprintln!("{}", e.line("memoryd", &lock_path));
return ExitCode::from(1);
}
};
let log: serve::Log = Arc::new(|line: &str| eprintln!("{line}"));
crates/gatewayd/src/main.rs
G1. The line
use bxcore::secrets;
becomes
use bxcore::{instance, secrets};
G2. In the comment at the top of serve, the line
// Then `if let Some(warning) = &loaded.warning { eprintln!("{warning}"); }`.
becomes
// Then `if let Some(warning) = &loaded.warning { eprintln!("{warning}"); }`.
// 3b. The instance lock, `<home>/run/gatewayd.lock` (M5b spec, section 9a): after the secret,
// which is read before anything is made, and before the directory and the socket.
G3. In serve, the lines
if let Some(warning) = &loaded.warning {
eprintln!("{warning}");
}
become
if let Some(warning) = &loaded.warning {
eprintln!("{warning}");
}
let lock_path = config.paths.home.join("run/gatewayd.lock");
let _instance = match instance::lock(&lock_path) {
Ok(held) => held,
Err(e) => {
eprintln!("{}", e.line("gatewayd", &lock_path));
return ExitCode::from(1);
}
};
Steps
-
1. Copy the given tests. From the repository root, four commands:
cp docs/plans/M5b/files/crates/brokerd/tests/instance.rs crates/brokerd/tests/ cp docs/plans/M5b/files/crates/brokerd/tests/serve.rs crates/brokerd/tests/ cp docs/plans/M5b/files/crates/memoryd/tests/instance.rs crates/memoryd/tests/ cp docs/plans/M5b/files/crates/gatewayd/tests/instance.rs crates/gatewayd/tests/ -
2. See them fail. Three commands, each with a shell timeout of 600000 ms. Each file compiles;
serve.rspasses.cargo test -p brokerd --no-fail-fast --test instance --test serve(--no-fail-fast, or cargo stops at the first failing file):serve8 passed.instance: 2 failed.a_second_brokerd_on_the_same_home_is_refused_and_the_first_keeps_servingfails onthe lock's line, and nothing else(the second stopped at the audit lock, withbrokerd is already running);after_the_first_is_killed_a_new_brokerd_removes_the_stale_sockets_and_servesfails onthe lock file, mode 0600,left: None,right: Some(384).cargo test -p memoryd --test instance: 2 failed (about 10 s).a_second_memoryd_on_the_same_home_is_refused_and_the_first_keeps_servingfails onthe second memoryd: …withleft: None,right: Some(1)(it started and served);after_the_first_is_killed_a_new_memoryd_removes_the_stale_sockets_and_servesonthe lock file, mode 0600,left: None.cargo test -p gatewayd --test instance: 2 failed (about 10 s), the same two shapes:the second gatewayd: …withleft: None, andthe lock file, mode 0600withleft: None.
-
3.
brokerd. Make edits B1 and B2.cargo check -p brokerd: no error.cargo test -p brokerd --test instance --test serve:instance2 passed,serve8 passed. -
4.
memoryd. Make edits D1 and D2.cargo check -p memoryd: no error.cargo test -p memoryd --test instance: 2 passed. -
5.
gatewayd. Make edits G1 to G3.cargo check -p gatewayd: no error.cargo test -p gatewayd --test instance --test main:instance2 passed,main5 passed. -
6. Check.
grep -rn 'instance::lock(' crates/*/srcprints exactly five lines, one in themain.rsof each ofbrokerd,gatewayd,inferproxy,loopdandmemoryd.grep -rn 'runbook.md#already-running' crates/*/srcstill prints exactly one line, incrates/bxcore/src/instance.rs.git diff --stat -- crates/bxcore '*Cargo.toml'prints nothing. -
7. Run the gate.
cargo fmt --all, thenmake gate, with a shell timeout of 600000 ms. Expected last line:gate: ok, with 1118 tests: 1113 before this task, minus 1 in the newcrates/brokerd/tests/serve.rs(its old second-instance test), plus 2 in each of the three newinstance.rsfiles. -
8. Log and commit.
git status --shortlistscrates/brokerd/src/main.rs,crates/memoryd/src/main.rs,crates/gatewayd/src/main.rs,crates/brokerd/tests/serve.rsand your log as modified, and the threeinstance.rstest files as new. Stage withgit add crates/brokerd crates/memoryd crates/gatewayd docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.Cargo.lockdoes not change in this task; thegit addis harmless.
Done when
make gateprintsgate: okwith 1118 tests.git diff HEAD~1 --statlists only the threemain.rsfiles,crates/brokerd/tests/serve.rs, the three newinstance.rstest files anddocs/implementer-log.md.
Stop and report if
- A "before" block of B1, B2, D1, D2 or G1 to G3 is not in its file exactly as shown, or is there more than once.
- Step 2 shows a different result: other failing tests, other messages, or other counts.
cargo checkreports an error after an edit made as shown. Quote it and stop.- After step 3, 4 or 5 a test fails although the edits are as shown. Do not change a test or
bxcore: stop, and give the failing tests' names and their messages. - The fix seems to need a change anywhere else:
bxcore, another file of a daemon, the test support files, aCargo.toml, the runbook. It does not; stop and say why you think so. - A test outside these files fails in the gate: check once whether it fails without your change, then stop and report both results.
- The gate passes with a count other than 1118.