diff --git a/packages/webui/src/org-admin.mjs b/packages/webui/src/org-admin.mjs index 72c612f20..c9c3acf4c 100644 --- a/packages/webui/src/org-admin.mjs +++ b/packages/webui/src/org-admin.mjs @@ -655,7 +655,8 @@ async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, tra let receipt; try { receipt = await bus.busStop(ctx, id); } catch (e) { - note('refused'); + // Only exit 3 comes before the signal; a timeout or an unexpected error may follow SIGTERM. + note(isCliError(e) && e.exitCode === 3 ? 'refused' : 'failed'); if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true }); return json(res, 409, { error: e.exitCode === 3 ? 'bus-refused' : 'bus-failed', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}.` }); } @@ -669,7 +670,8 @@ async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, tra } note('done'); log(`org-admin: ${id} bus stopped`); - return json(res, 200, { outcome: 'done', step, stopped: true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) }); + // A host that exited on its own between the read and the stop was not stopped by this press. + return json(res, 200, { outcome: 'done', step, stopped: receipt?.stopped === true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) }); } // The dev-local owner exactly (packages/cli/src/dev-local-owner.mjs). A signed-in session carries diff --git a/packages/webui/tests/org-admin.test.mjs b/packages/webui/tests/org-admin.test.mjs index f7fc584c9..5bdf60471 100644 --- a/packages/webui/tests/org-admin.test.mjs +++ b/packages/webui/tests/org-admin.test.mjs @@ -840,6 +840,10 @@ test('stop: a stop that is refused, times out or leaves state behind is a visibl bus.busStop = async () => { throw Object.assign(new Error('bus host pid 4242 did not stop within 60 s'), { name: 'CliError', exitCode: 1 }); }; r = await c.call('POST', STOP, {}); assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed'); + // An unexpected error may come after the signal: the outcome is unknown and the trail says failed. + bus.busStop = async () => { throw new Error('EPERM'); }; + r = await c.call('POST', STOP, {}); + assert.equal(r.status, 502, r.raw); assert.equal(r.body.error, 'outcome-unknown'); // The host was signalled and exited, but a lock is left, or the host is still recorded. let after; bus.busStop = async () => { bus.busStatus = after; return { stopped: true, business: 'acme', pid: 4242 }; }; @@ -851,7 +855,13 @@ test('stop: a stop that is refused, times out or leaves state behind is a visibl r = await c.call('POST', STOP, {}); assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed'); assert.match(r.body.message, /^the bus host was stopped, but a bus host still runs\./); - assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'refused', 'requested', 'failed', 'requested', 'failed']); + assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed']); + // The host exited on its own between the read and the stop: done, but this press stopped nothing. + bus.busStatus = live; + bus.busStop = async () => { bus.busStatus = async () => ({ org: 'acme', healthy: false, pid: null }); return { stopped: false, reason: 'no host runs' }; }; + r = await c.call('POST', STOP, {}); + assert.equal(r.status, 200, r.raw); + assert.deepEqual([r.body.outcome, r.body.state, r.body.stopped, r.body.receipt.stopped], ['done', 'stopped', false, false]); }); test('stop: an archived organization, or a Console whose bus control has no stop, refuses', async t => {