- busRecover: a live pid with the recorded start time is the recorded process, so it refuses
("bus host identity is uncertain") whatever its argv. It used to read a mismatching argv as a dead
host. The pinned recovery test now covers a reused pid (live, other start time) as the wrong
process, and a new test covers the refusal.
- busRecover clears through setAside: each inspected entry is renamed aside and checked by device
and inode; a swapped entry puts everything back and refuses before any receipt or deletion, and
the deletion names only the aside entries.
- mosaic bus recover refuses inside an agent run (refuseInsideAgent), like mosaic org reload. The
Console-start revive does nothing when the Console's own environment carries an agent marker.
- Tests: org-recovery (identity, agent refusal), org-revive (setAside swap), bus-revive (markers);
the other reviveBuses tests pass an explicit env.
163 lines
10 KiB
JavaScript
163 lines
10 KiB
JavaScript
// D2 (P0-3): a Console unit stop (KillMode=mixed) SIGKILLs the bus hosts the Console started. These
|
|
// tests start real bus hosts, kill them the same way, and check that busRevive and the Console-start
|
|
// revive bring them back, and that a live host or a live writer lock is never taken over.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { reserveOrg, activateOrg, listOrgs } from '../../organization/src/org-registry.mjs';
|
|
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
|
import { humanEnv, notifyConfig } from './helpers.mjs';
|
|
import { busRevive, busStart, busStatus, busStop, setAside } from '../src/org-bus.mjs';
|
|
import { reviveBuses } from '../../webui/src/bus-revive.mjs';
|
|
|
|
const REPO = new URL('../../../', import.meta.url).pathname;
|
|
const IDS = ['alpha', 'beta'];
|
|
|
|
async function instance(t) {
|
|
const home = mkdtempSync(join(tmpdir(), 'rv-'));
|
|
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-rvtest/config.json') });
|
|
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'rvtest'], { env, encoding: 'utf8' });
|
|
assert.equal(setup.status, 0, setup.stderr);
|
|
const ctx = { configDir: join(home, '.config/mosaic-rvtest'), dataRoot: join(home, '.mosaic-rvtest'), instance: 'rvtest', env };
|
|
env.MOSAIC_ROLES_DIR = rolesCopy(home);
|
|
const roots = {};
|
|
for (const id of IDS) {
|
|
const org = await reserveOrg(ctx, id);
|
|
await activateOrg(ctx, id);
|
|
writeJson(join(ctx.configDir, 'businesses', `${id}.json`), businessDoc(home, id));
|
|
notifyConfig(org.root, id, null);
|
|
roots[id] = org.root;
|
|
}
|
|
const extra = [];
|
|
t.after(async () => {
|
|
for (const child of extra) { try { child.kill('SIGKILL'); } catch {} }
|
|
for (const id of IDS) { try { await busStop(ctx, id); } catch {} }
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
return { ctx, roots, extra };
|
|
}
|
|
// What a Console unit stop does to a host it started: SIGKILL to the host and every process under it.
|
|
// The host is a session leader (detached), so its process group is the test's own; `done` resolves
|
|
// once the host is reaped, and the loop waits for the rest of the group to go.
|
|
async function kill(receipt) {
|
|
process.kill(-receipt.pid, 'SIGKILL');
|
|
await receipt.done;
|
|
for (let i = 0; i < 100; i++) {
|
|
try { process.kill(-receipt.pid, 0); } catch (e) { if (e.code === 'ESRCH') return; throw e; }
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
throw new Error(`process group ${receipt.pid} did not go`);
|
|
}
|
|
const host = root => JSON.parse(readFileSync(join(root, 'bus-host/host.json'), 'utf8'));
|
|
const recoveries = root => readdirSync(join(root, 'bus-host')).filter(name => /^recovery-.*\.json$/.test(name));
|
|
|
|
test('D2: a SIGKILLed bus host comes back from one revive, on the options it was started with', { timeout: 120000 }, async t => {
|
|
const { ctx, roots } = await instance(t);
|
|
const first = await busStart(ctx, 'alpha', { detach: true, devLocalOwner: true, noLaunch: true });
|
|
await kill(first);
|
|
// What a Console unit stop leaves behind: the dead host's state, lock and socket.
|
|
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
|
|
await assert.rejects(busStatus(ctx, 'alpha'), e => e.name === 'CliError' && /stale/.test(e.message));
|
|
const revived = await busRevive(ctx, 'alpha');
|
|
assert.notEqual(revived.pid, first.pid);
|
|
assert.equal(revived.recovered.pid, first.pid);
|
|
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
|
|
assert.deepEqual(host(roots.alpha).options, { devLocalOwner: true, pm: false, noLaunch: true });
|
|
assert.equal(recoveries(roots.alpha).length, 1);
|
|
const receipt = JSON.parse(readFileSync(revived.recovered.receipt, 'utf8'));
|
|
assert.equal(receipt.host.pid, first.pid);
|
|
});
|
|
|
|
test('D2: a live bus host is never taken over, and its state is left as it was', { timeout: 120000 }, async t => {
|
|
const { ctx, roots } = await instance(t);
|
|
const live = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
|
|
const before = readFileSync(join(roots.alpha, 'bus-host/host.json'), 'utf8');
|
|
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && e.message === `bus host pid ${live.pid} is alive`);
|
|
assert.equal(readFileSync(join(roots.alpha, 'bus-host/host.json'), 'utf8'), before);
|
|
assert.equal(recoveries(roots.alpha).length, 0);
|
|
const status = await busStatus(ctx, 'alpha');
|
|
assert.equal(status.healthy, true);
|
|
assert.equal(status.pid, live.pid);
|
|
});
|
|
|
|
test('D2: a writer lock held by a live process refuses the revive, even with the host dead', { timeout: 120000 }, async t => {
|
|
const { ctx, roots, extra } = await instance(t);
|
|
const first = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
|
|
await kill(first);
|
|
// A live process the test started holds the writer lock.
|
|
const holder = spawn('sleep', ['60'], { stdio: 'ignore' }); extra.push(holder);
|
|
writeFileSync(join(roots.alpha, 'bus/writer.lock'), JSON.stringify({ pid: holder.pid, at: new Date().toISOString() }), { mode: 0o600 });
|
|
const lockBefore = readFileSync(join(roots.alpha, 'bus/writer.lock'), 'utf8');
|
|
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && e.message === `bus writer pid ${holder.pid} is alive`);
|
|
assert.equal(readFileSync(join(roots.alpha, 'bus/writer.lock'), 'utf8'), lockBefore);
|
|
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
|
|
assert.equal(recoveries(roots.alpha).length, 0);
|
|
// The Console-start revive leaves it too, and logs the refusal.
|
|
const lines = [], trail = [];
|
|
const results = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: line => lines.push(line) });
|
|
assert.deepEqual(results.find(r => r.id === 'alpha'), { id: 'alpha', outcome: 'refused' });
|
|
assert.ok(lines.some(line => line.includes(`alpha: refused (bus writer pid ${holder.pid} is alive)`)), lines.join('\n'));
|
|
assert.deepEqual(trail.filter(e => e.org === 'alpha').map(e => e.outcome), ['requested', 'refused']);
|
|
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
|
|
});
|
|
|
|
test('D2: Console start brings both killed buses back and reattaches to one that still runs', { timeout: 180000 }, async t => {
|
|
const { ctx, roots } = await instance(t);
|
|
const a = await busStart(ctx, 'alpha', { detach: true, devLocalOwner: true, noLaunch: true });
|
|
const b = await busStart(ctx, 'beta', { detach: true, devLocalOwner: true, noLaunch: true });
|
|
await kill(a); await kill(b);
|
|
const trail = [], lines = [];
|
|
const results = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: line => lines.push(line) });
|
|
assert.deepEqual(results.map(r => [r.id, r.outcome]), [['alpha', 'revived'], ['beta', 'revived']]);
|
|
for (const id of IDS) {
|
|
const status = await busStatus(ctx, id);
|
|
assert.equal(status.healthy, true, id);
|
|
assert.equal(host(roots[id]).options.devLocalOwner, true);
|
|
assert.deepEqual(trail.filter(e => e.org === id).map(e => [e.action, e.outcome, e.by.sub]), [['bus.revive', 'requested', 'local-dev-owner'], ['bus.revive', 'done', 'local-dev-owner']]);
|
|
}
|
|
// A second Console start finds them running and leaves them.
|
|
const pids = Object.fromEntries(await Promise.all(IDS.map(async id => [id, (await busStatus(ctx, id)).pid])));
|
|
const again = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: () => {} });
|
|
assert.deepEqual(again.map(r => [r.id, r.outcome]), [['alpha', 'running'], ['beta', 'running']]);
|
|
for (const id of IDS) assert.equal((await busStatus(ctx, id)).pid, pids[id]);
|
|
});
|
|
|
|
test('D2: a start that fails after the dead state was cleared says so (error.recovered)', { timeout: 120000 }, async t => {
|
|
const { ctx, roots } = await instance(t);
|
|
const first = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
|
|
await kill(first);
|
|
// A record busStart refuses (--pm with --no-launch), rewritten in place: the clear goes through and
|
|
// the start that follows fails, the way a host that cannot come up would.
|
|
const file = join(roots.alpha, 'bus-host/host.json');
|
|
writeFileSync(file, JSON.stringify({ ...host(roots.alpha), options: { devLocalOwner: false, pm: true, noLaunch: true } }));
|
|
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && /--pm or --no-launch/.test(e.message) && e.recovered?.pid === first.pid);
|
|
assert.equal(existsSync(file), false);
|
|
assert.equal(recoveries(roots.alpha).length, 1);
|
|
});
|
|
|
|
test('D2: an entry swapped after inspection is put back and nothing is removed', t => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'aside-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const a = join(dir, 'host.json'), b = join(dir, 'writer.lock');
|
|
writeFileSync(a, 'dead host', { mode: 0o600 }); writeFileSync(b, 'dead writer', { mode: 0o600 });
|
|
const entries = [{ path: a, st: lstatSync(a) }, { path: b, st: lstatSync(b) }];
|
|
// The swap: the inspected writer.lock is moved off (its inode stays in use) and a new one takes the name.
|
|
renameSync(b, join(dir, 'kept')); writeFileSync(b, 'live writer', { mode: 0o600 });
|
|
assert.throws(() => setAside(entries), e => e.name === 'CliError' && e.message === 'bus recovery state changed');
|
|
assert.equal(readFileSync(a, 'utf8'), 'dead host');
|
|
assert.equal(readFileSync(b, 'utf8'), 'live writer');
|
|
assert.deepEqual(readdirSync(dir).sort(), ['host.json', 'kept', 'writer.lock']);
|
|
// Unswapped, the entries go aside and remove() deletes only those.
|
|
renameSync(join(dir, 'kept'), b);
|
|
const fresh = [{ path: a, st: lstatSync(a) }, { path: b, st: lstatSync(b) }];
|
|
const aside = setAside(fresh);
|
|
assert.equal(existsSync(a) || existsSync(b), false);
|
|
writeFileSync(a, 'a new host', { mode: 0o600 });
|
|
aside.remove();
|
|
assert.deepEqual(readdirSync(dir), ['host.json']);
|
|
assert.equal(readFileSync(a, 'utf8'), 'a new host');
|
|
});
|