diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-22 18:26:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-22 18:26:22 +0200 |
| commit | 8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da (patch) | |
| tree | e172bb4d596bde53c48ce2ca3ef2d59c98968147 | |
| parent | b4baa4770d517ea7d1d25bb0ac50fc7c989531d6 (diff) | |
| download | meshbay-8dc11dc05a35a5d64ba4d2c892ccc01c7bfae3da.tar.gz | |
feat(node): systemd service panel on the Node page, and a clean CLI restart
Add a status panel at the top of the Node page — always visible, even
before an MNP connection exists — showing the meshbay-node systemd
unit's own state (via `systemctl --user show`, main process only) with
Start/Stop/Restart controls. This is the piece the rest of the page
cannot provide: it has to work while the daemon is stopped or crash-
looping, which the MNP-based sections require the daemon to already
answer.
While touching node lifecycle: `reload` and `restart-daemon` in the
CLI shelled out to pgrep + SIGTERM/SIGHUP and respawned the process by
hand, logging to a hardcoded /tmp path. That pattern already SIGHUPed
a developer's own running node by accident once (see the old
test_cli_dispatch.py comment). Both now delegate to
`systemctl --user reload|restart meshbay-node`, which the unit already
supports correctly (ExecReload=, Restart=on-failure).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
17 files changed, 438 insertions, 72 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 24c1b6a..2bdf550 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -738,6 +738,63 @@ function registerBridge() { return { installed: Boolean(bin) }; }); + // The systemd unit's own view of the node, for the status panel at the top + // of the Node page. Deliberately not `probeNode()`: that asks the daemon's + // own HTTP API, which cannot answer while the daemon is stopped or crash- + // looping — exactly the states this panel exists to show and act on. + ipcMain.handle('node:service-status', async () => { + if (process.platform !== 'linux') return { supported: false }; + return new Promise((resolve) => { + execFile('systemctl', ['--user', 'show', 'meshbay-node.service', + '--property=LoadState,ActiveState,SubState'], (err, stdout) => { + if (err) { + resolve({ supported: true, installed: false, activeState: 'unknown', + subState: '' }); + return; + } + const props = {}; + for (const line of stdout.split('\n')) { + const i = line.indexOf('='); + if (i > 0) props[line.slice(0, i)] = line.slice(i + 1); + } + resolve({ + supported: true, + installed: props.LoadState === 'loaded', + activeState: props.ActiveState || 'unknown', + subState: props.SubState || '', + }); + }); + }); + }); + + ipcMain.handle('node:service-stop', async () => { + if (process.platform !== 'linux') { + throw new Error('Service control is only supported on Linux'); + } + await new Promise((resolve, reject) => { + execFile('systemctl', ['--user', 'stop', 'meshbay-node'], + (err, _stdout, stderr) => { + if (err) return reject(new Error(stderr.trim() || err.message)); + resolve(); + }); + }); + return { stopped: true }; + }); + + ipcMain.handle('node:service-restart', async () => { + if (process.platform !== 'linux') { + throw new Error('Service control is only supported on Linux'); + } + await new Promise((resolve, reject) => { + execFile('systemctl', ['--user', 'restart', 'meshbay-node'], + (err, _stdout, stderr) => { + if (err) return reject(new Error(stderr.trim() || err.message)); + resolve(); + }); + }); + return { restarted: true }; + }); + async function probeNode() { const nc = readNodeConfig(); const dataDir = nc ? nc.dataDir diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index f240995..7ffdf66 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -91,6 +91,13 @@ contextBridge.exposeInMainWorld('meshbay', { call: (method, path, body) => ipcRenderer.invoke('node:call', method, path, body), pairingCode: () => ipcRenderer.invoke('node:pairing-code'), setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), + // The systemd unit's own state — reachable even while the daemon itself + // is stopped or crash-looping, which `call()` above is not. + service: { + status: () => ipcRenderer.invoke('node:service-status'), + stop: () => ipcRenderer.invoke('node:service-stop'), + restart: () => ipcRenderer.invoke('node:service-restart'), + }, }, // LAN cast relay. The main process runs a local HTTP server and the diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index b863460..36df0cd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -5633,6 +5633,90 @@ function BlocklistForm({ onAdd }) { // ── Node management (D5) ──────────────────────────────────────────────────── +/** + * The systemd unit's own state, independent of the MNP connection below it. + * + * The rest of NodePage talks to the daemon over a signed MNP session, which + * requires the daemon to already be up and answering — no use at all for + * "the node is stopped, start it" or "it is crash-looping, tell me". This + * asks systemd directly (main process → `systemctl --user`), the same way + * `node:installed` and the wizard's `node:start` already do, so it works + * from every state the connection below can be in. + */ +function NodeServicePanel({ onChanged }) { + const [info, setInfo] = useState(null); + const [busy, setBusy] = useState(''); + const [err, setErr] = useState(''); + + const refresh = useCallback(async () => { + try { + const r = await platform.node.service.status(); + setInfo(r); + setErr(''); + } catch (e) { + setErr(platform.bridgeMessage(e)); + } + }, []); + + useEffect(() => { + if (!platform.node.service.available) return; + refresh(); + const timer = setInterval(refresh, 5000); + return () => clearInterval(timer); + }, [refresh]); + + const act = useCallback(async (name, fn) => { + setBusy(name); + setErr(''); + try { + await fn(); + await refresh(); + if (onChanged) onChanged(); + } catch (e) { + setErr(platform.bridgeMessage(e)); + } finally { + setBusy(''); + } + }, [refresh, onChanged]); + + if (!platform.node.service.available) return null; + if (!info || info.supported === false) { + return html`<div class="node-service"> + <span class="spinner"></span>${' '}${t('node.service_checking')} + </div>`; + } + + const KNOWN_STATES = ['active', 'inactive', 'failed', 'activating', 'deactivating']; + const stateKey = KNOWN_STATES.includes(info.activeState) ? info.activeState : 'unknown'; + const running = info.activeState === 'active' || info.activeState === 'activating'; + const dot = info.activeState === 'active' ? 'online' + : info.activeState === 'failed' ? 'offline' : 'unknown'; + const label = info.installed ? t('node.service_state_' + stateKey) + : t('node.service_not_installed'); + + return html` + <div class="node-service"> + <div class="node-service-status"> + <span class="presence presence-${dot}" title="${label}" aria-label="${label}"></span> + <span>${label}</span> + </div> + ${err && html`<div class="error-msg">${err}</div>`} + <div class="node-service-actions"> + <button class="btn btn-small btn-secondary" disabled=${!!busy || running} + onClick=${() => act('start', () => platform.node.start())}> + ${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button> + ${info.installed && html` + <button class="btn btn-small btn-secondary" disabled=${!!busy || !running} + onClick=${() => act('stop', () => platform.node.service.stop())}> + ${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button> + <button class="btn btn-small btn-secondary" disabled=${!!busy} + onClick=${() => act('restart', () => platform.node.service.restart())}> + ${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}</button> + `} + </div> + </div>`; +} + function NodePage({ token, username, userId, groups }) { const [status, setStatus] = useState('idle'); const [error, setError] = useState(''); @@ -5918,17 +6002,22 @@ function NodePage({ token, username, userId, groups }) { if (status === 'idle' || status === 'connecting') { return html`<div class="page-content"> + <h2>${t('node.title')}</h2> + <${NodeServicePanel} onChanged=${connectAndFetch} /> <p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p> </div>`; } if (status === 'no_groups') { return html`<div class="page-content"> + <h2>${t('node.title')}</h2> + <${NodeServicePanel} onChanged=${connectAndFetch} /> <p class="page-message">${t('node.no_groups')}</p> </div>`; } if (status === 'error') { return html`<div class="page-content"> <h2>${t('node.title')}</h2> + <${NodeServicePanel} onChanged=${connectAndFetch} /> <p class="error-msg">${error}</p> <button class="btn btn-primary" onClick=${connectAndFetch}> ${t('node.retry')}</button> @@ -5943,6 +6032,7 @@ function NodePage({ token, username, userId, groups }) { onClick=${reloadConfig}> ${t('node.reload')}</button> </div> + <${NodeServicePanel} onChanged=${connectAndFetch} /> ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} ${(() => { const hubIds = new Set((groups || []).map(g => g.id)); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 4df7781..1a151e2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -490,6 +490,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': 'Node-Dienst wird überprüft…', + 'node.service_not_installed': 'Nicht als Dienst installiert', + 'node.service_state_active': 'Wird ausgeführt', + 'node.service_state_inactive': 'Angehalten', + 'node.service_state_failed': 'Fehlgeschlagen', + 'node.service_state_activating': 'Wird gestartet…', + 'node.service_state_deactivating': 'Wird angehalten…', + 'node.service_state_unknown': 'Unbekannt', + 'node.service_start': 'Starten', + 'node.service_starting': 'Wird gestartet…', + 'node.service_stop': 'Anhalten', + 'node.service_stopping': 'Wird angehalten…', + 'node.service_restart': 'Neu starten', + 'node.service_restarting': 'Wird neu gestartet…', 'node.not_operator': 'Ihr Node konnte nicht erreicht werden. Stellen Sie sicher, dass er läuft.', 'node.offline': 'Node ist offline', 'node.retry': 'Erneut versuchen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 3ddd7f1..dfd400b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -501,6 +501,20 @@ export default { // Node management (D5) 'sidebar.node': 'Node', 'node.title': 'Node', + 'node.service_checking': 'Checking node service…', + 'node.service_not_installed': 'Not installed as a service', + 'node.service_state_active': 'Running', + 'node.service_state_inactive': 'Stopped', + 'node.service_state_failed': 'Failed', + 'node.service_state_activating': 'Starting…', + 'node.service_state_deactivating': 'Stopping…', + 'node.service_state_unknown': 'Unknown', + 'node.service_start': 'Start', + 'node.service_starting': 'Starting…', + 'node.service_stop': 'Stop', + 'node.service_stopping': 'Stopping…', + 'node.service_restart': 'Restart', + 'node.service_restarting': 'Restarting…', 'node.offline': 'Node is offline', 'node.no_groups': 'No groups configured on this node.', 'node.retry': 'Retry', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 093b1e7..498fc38 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -485,6 +485,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': 'Comprobando el servicio del nodo…', + 'node.service_not_installed': 'No instalado como servicio', + 'node.service_state_active': 'En ejecución', + 'node.service_state_inactive': 'Detenido', + 'node.service_state_failed': 'Fallido', + 'node.service_state_activating': 'Iniciando…', + 'node.service_state_deactivating': 'Deteniendo…', + 'node.service_state_unknown': 'Desconocido', + 'node.service_start': 'Iniciar', + 'node.service_starting': 'Iniciando…', + 'node.service_stop': 'Detener', + 'node.service_stopping': 'Deteniendo…', + 'node.service_restart': 'Reiniciar', + 'node.service_restarting': 'Reiniciando…', 'node.not_operator': 'No se pudo contactar con su node. Asegúrese de que esté en ejecución.', 'node.offline': 'Node sin conexión', 'node.retry': 'Reintentar', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 43036dd..9e21135 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -489,6 +489,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': 'Vérification du service du nœud…', + 'node.service_not_installed': 'Non installé en tant que service', + 'node.service_state_active': 'En cours d’exécution', + 'node.service_state_inactive': 'Arrêté', + 'node.service_state_failed': 'Échec', + 'node.service_state_activating': 'Démarrage…', + 'node.service_state_deactivating': 'Arrêt en cours…', + 'node.service_state_unknown': 'Inconnu', + 'node.service_start': 'Démarrer', + 'node.service_starting': 'Démarrage…', + 'node.service_stop': 'Arrêter', + 'node.service_stopping': 'Arrêt…', + 'node.service_restart': 'Redémarrer', + 'node.service_restarting': 'Redémarrage…', 'node.not_operator': 'Impossible de joindre votre node. Vérifiez qu\'il est en cours d\'exécution.', 'node.offline': 'Node hors ligne', 'node.retry': 'Réessayer', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index af22add..7e2b100 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -487,6 +487,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': 'Verifica del servizio del nodo…', + 'node.service_not_installed': 'Non installato come servizio', + 'node.service_state_active': 'In esecuzione', + 'node.service_state_inactive': 'Arrestato', + 'node.service_state_failed': 'Non riuscito', + 'node.service_state_activating': 'Avvio…', + 'node.service_state_deactivating': 'Arresto…', + 'node.service_state_unknown': 'Sconosciuto', + 'node.service_start': 'Avvia', + 'node.service_starting': 'Avvio…', + 'node.service_stop': 'Arresta', + 'node.service_stopping': 'Arresto…', + 'node.service_restart': 'Riavvia', + 'node.service_restarting': 'Riavvio…', 'node.not_operator': 'Impossibile raggiungere il suo node. Si assicuri che sia in esecuzione.', 'node.offline': 'Node non in linea', 'node.retry': 'Riprova', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 2aa2ab5..ee55fc2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -475,6 +475,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': 'ノードサービスを確認中…', + 'node.service_not_installed': 'サービスとしてインストールされていません', + 'node.service_state_active': '実行中', + 'node.service_state_inactive': '停止', + 'node.service_state_failed': '失敗', + 'node.service_state_activating': '起動中…', + 'node.service_state_deactivating': '停止中…', + 'node.service_state_unknown': '不明', + 'node.service_start': '開始', + 'node.service_starting': '起動中…', + 'node.service_stop': '停止', + 'node.service_stopping': '停止中…', + 'node.service_restart': '再起動', + 'node.service_restarting': '再起動中…', 'node.not_operator': 'node に接続できませんでした。node が実行中であることをご確認ください。', 'node.offline': 'Node はオフラインです', 'node.retry': '再試行', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 5a9a303..986d653 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -489,6 +489,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': 'Node-service controleren…', + 'node.service_not_installed': 'Niet als service geïnstalleerd', + 'node.service_state_active': 'Actief', + 'node.service_state_inactive': 'Gestopt', + 'node.service_state_failed': 'Mislukt', + 'node.service_state_activating': 'Starten…', + 'node.service_state_deactivating': 'Stoppen…', + 'node.service_state_unknown': 'Onbekend', + 'node.service_start': 'Starten', + 'node.service_starting': 'Starten…', + 'node.service_stop': 'Stoppen', + 'node.service_stopping': 'Stoppen…', + 'node.service_restart': 'Herstarten', + 'node.service_restarting': 'Herstarten…', 'node.not_operator': 'Uw node is niet bereikbaar. Controleer of hij draait.', 'node.offline': 'Node is offline', 'node.retry': 'Opnieuw proberen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index c8c0315..ca3f60c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -502,6 +502,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': 'Sprawdzanie usługi węzła…', + 'node.service_not_installed': 'Nie zainstalowano jako usługi', + 'node.service_state_active': 'Uruchomiona', + 'node.service_state_inactive': 'Zatrzymana', + 'node.service_state_failed': 'Niepowodzenie', + 'node.service_state_activating': 'Uruchamianie…', + 'node.service_state_deactivating': 'Zatrzymywanie…', + 'node.service_state_unknown': 'Nieznany', + 'node.service_start': 'Uruchom', + 'node.service_starting': 'Uruchamianie…', + 'node.service_stop': 'Zatrzymaj', + 'node.service_stopping': 'Zatrzymywanie…', + 'node.service_restart': 'Uruchom ponownie', + 'node.service_restarting': 'Ponowne uruchamianie…', 'node.not_operator': 'Nie udało się połączyć z Pana/Pani node. Upewnij się, że działa.', 'node.offline': 'Node jest niedostępny', 'node.retry': 'Ponów', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 1f7bee2..8231daa 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -486,6 +486,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': 'Verificando o serviço do nó…', + 'node.service_not_installed': 'Não instalado como serviço', + 'node.service_state_active': 'Em execução', + 'node.service_state_inactive': 'Parado', + 'node.service_state_failed': 'Falhou', + 'node.service_state_activating': 'Iniciando…', + 'node.service_state_deactivating': 'Parando…', + 'node.service_state_unknown': 'Desconhecido', + 'node.service_start': 'Iniciar', + 'node.service_starting': 'Iniciando…', + 'node.service_stop': 'Parar', + 'node.service_stopping': 'Parando…', + 'node.service_restart': 'Reiniciar', + 'node.service_restarting': 'Reiniciando…', 'node.not_operator': 'Não foi possível alcançar seu node. Verifique se ele está em execução.', 'node.offline': 'Node está off-line', 'node.retry': 'Tentar novamente', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 162fadb..48122d3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -461,6 +461,20 @@ export default { // Node admin 'node.title': 'Node', + 'node.service_checking': '正在检查节点服务…', + 'node.service_not_installed': '未安装为服务', + 'node.service_state_active': '运行中', + 'node.service_state_inactive': '已停止', + 'node.service_state_failed': '失败', + 'node.service_state_activating': '正在启动…', + 'node.service_state_deactivating': '正在停止…', + 'node.service_state_unknown': '未知', + 'node.service_start': '启动', + 'node.service_starting': '正在启动…', + 'node.service_stop': '停止', + 'node.service_stopping': '正在停止…', + 'node.service_restart': '重启', + 'node.service_restarting': '正在重启…', 'node.not_operator': '无法连接到您的 node。请确保它正在运行。', 'node.offline': 'Node 已离线', 'node.retry': '重试', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index a8e17bf..30df80e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -241,6 +241,28 @@ export const node = { async setPairingCode(code) { return bridge && bridge.node ? bridge.node.setPairingCode(code) : false; }, + /** + * The systemd unit's own state — start, stop and restart the node as a + * service, independent of whether the daemon itself is answering. Absent + * in a browser, same as the rest of `node`. + */ + service: { + available: Boolean(bridge && bridge.node && bridge.node.service), + async status() { + if (!bridge || !bridge.node || !bridge.node.service) return { supported: false }; + return bridge.node.service.status(); + }, + async stop() { + if (!bridge || !bridge.node || !bridge.node.service) + throw new Error('Node bridge not available'); + return bridge.node.service.stop(); + }, + async restart() { + if (!bridge || !bridge.node || !bridge.node.service) + throw new Error('Node bridge not available'); + return bridge.node.service.restart(); + }, + }, }; /** diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index ee51a8a..20b9f8c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2181,6 +2181,27 @@ a.transfer-name { } .node-header h2 { margin-bottom: 0; } +.node-service { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + padding: 10px 12px; + margin-bottom: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-raised); +} +.node-service-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9em; +} +.node-service-actions { display: flex; gap: 8px; } +.node-service .error-msg { flex-basis: 100%; margin: 0; } + .node-section { margin-top: 16px; } .node-section-header { display: flex; diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 5fc70fe..b34e710 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -888,6 +888,32 @@ def _resolve_group(cfg: Config, group: str | None) -> str: sys.exit(1) +def _systemctl_user(verb: str, unit: str, *, not_running_hint: str, + success: str, watch: str | None) -> None: + """ + Run `systemctl --user <verb> <unit>` and report the result. + + The lifecycle authority is the unit, not this process: systemd already + knows which PID it started, restarts it on failure (`Restart=on-failure` + in the unit) and reloads it correctly (`ExecReload=`). Anything this CLI + did instead — finding a process by pattern-matching its command line, + signalling it, respawning it — is a second, worse implementation of what + systemd is already doing, and pattern-matching a process list has already + hit a real developer's real running node by accident. + """ + import subprocess + + result = subprocess.run(["systemctl", "--user", verb, unit], + capture_output=True, text=True) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + print(detail or not_running_hint) + sys.exit(1) + print(success) + if watch: + print(watch) + + # ── Entry point ─────────────────────────────────────────────────────────────── def main() -> None: @@ -904,7 +930,8 @@ def main() -> None: "browser with this node | member list|invite|revoke|unpin " "| group list|add|remove | gek init|rotate | file list|rm " "| denylist show|clear | reload: re-read node.toml " - "| restart-daemon: full stop + start " + "(systemctl --user reload) | restart-daemon: restart " + "the systemd unit (systemctl --user restart) " "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", help="'pair' for operator; list|invite|revoke|unpin for " @@ -1159,69 +1186,33 @@ def main() -> None: if args.command == "reload": # Milestone 14.8. The daemon re-reads node.toml; groups that appeared or # whose roots changed are picked up without dropping live connections. - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) - import os as _os - import signal as _signal - import subprocess as _subprocess - # `--` is pgrep's own end-of-options marker and must be its own argument; - # folded into the pattern it searches for a process literally called - # "-- -m …". Anchored to the end of the command line so it matches the - # daemon and never a shell that merely mentions it — the same trap - # deploy-node.sh documents, where an unanchored pattern kills the script. - pid_out = _subprocess.run( - ["pgrep", "-f", "--", r"-m meshbay_node\.daemon$"], - capture_output=True, text=True) - pids = [int(x) for x in pid_out.stdout.split()] - if not pids: - print("Node is not running — start it with: meshbay-node") - sys.exit(1) - for pid in pids: - _os.kill(pid, _signal.SIGHUP) - print(f"sent SIGHUP to {len(pids)} daemon process(es)") - print("watch the result: tail -f /tmp/meshbay-node.log") + # + # Delegated to systemd rather than hunting a PID with pgrep and signalling + # it directly: an unanchored (or merely unlucky) pattern match there has + # already SIGHUPed a developer's own running node by accident — see the + # comment this replaced, and test_cli_dispatch.py's stub_daemon fixture, + # which had to stub os.kill for exactly that reason. The unit already + # declares `ExecReload=/bin/kill -HUP $MAINPID`, so systemd sends the + # signal to the one process it actually started. + _systemctl_user( + "reload", "meshbay-node", + not_running_hint="Node is not running as a systemd unit — start it " + "with: systemctl --user start meshbay-node", + success="sent reload to meshbay-node", + watch="watch the result: journalctl --user -u meshbay-node -f") return if args.command == "restart-daemon": - import os as _os - import signal as _signal - import subprocess as _subprocess - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) - pid_out = _subprocess.run( - ["pgrep", "-f", "--", r"-m meshbay_node\.daemon$"], - capture_output=True, text=True) - pids = [int(x) for x in pid_out.stdout.split()] - if pids: - for pid in pids: - _os.kill(pid, _signal.SIGTERM) - print(f"stopped {len(pids)} daemon process(es)") - for pid in pids: - try: - _os.waitpid(pid, 0) - except ChildProcessError: - import time as _time - _time.sleep(2) - else: - print("no running daemon found — starting fresh") - log_path = "/tmp/meshbay-node.log" - config_flag = ["--config", str(args.config)] if args.config else [] - _subprocess.Popen( - [sys.executable, "-m", "meshbay_node.daemon"] + config_flag, - stdout=open(log_path, "a"), - stderr=_subprocess.STDOUT, - start_new_session=True, - ) - import time as _time - _time.sleep(3) - pid_out2 = _subprocess.run( - ["pgrep", "-f", "--", r"-m meshbay_node\.daemon$"], - capture_output=True, text=True) - new_pids = [int(x) for x in pid_out2.stdout.split()] - if new_pids: - print(f"daemon started (PID {new_pids[0]})") - print(f"log: tail -f {log_path}") - else: - print(f"daemon may have failed to start — check {log_path}") - sys.exit(1) + # Same reasoning as reload: no PID hunting, no manual respawn — systemd + # already knows how to stop and start this unit, and does not need this + # process to guess where its log file is. + _systemctl_user( + "restart", "meshbay-node", + not_running_hint="meshbay-node is not installed as a systemd unit — " + "see packaging/systemd/", + success="meshbay-node restarted via systemd", + watch="check status: systemctl --user status meshbay-node\n" + "watch logs: journalctl --user -u meshbay-node -f") return if args.command == "denylist": diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index fdcd82e..d9edb17 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -77,19 +77,21 @@ def stub_daemon(monkeypatch, tmp_path): import getpass monkeypatch.setattr(getpass, "getpass", lambda *a, **kw: "test-password") - # `reload` looks for a real daemon and signals it. Without this the test - # SIGHUPs whatever node happens to be running on the machine — which it did, - # once, before this was added. A test must not reach outside itself. - import os + # `reload` and `restart-daemon` shell out to `systemctl --user`. Without + # this the test would run that against whatever session bus is actually + # available — a test must not reach outside itself, which is exactly what + # the pgrep/os.kill version of this fixture existed to prevent before + # those commands were rewritten to delegate to systemd. import subprocess - signalled: list[int] = [] - monkeypatch.setattr( - subprocess, "run", - lambda *a, **k: subprocess.CompletedProcess(a[0], 0, stdout="4242\n", - stderr="")) - monkeypatch.setattr(os, "kill", lambda pid, sig: signalled.append(pid)) - calls.append(("_signalled", signalled)) + systemctl_calls: list[list[str]] = [] + + def fake_run(argv, **kw): + systemctl_calls.append(argv) + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + calls.append(("_systemctl_calls", systemctl_calls)) return calls @@ -130,3 +132,39 @@ def test_the_verb_list_here_matches_the_parser(): assert not untested, ( f"CLI verbs with no dispatch test: {sorted(untested)} — add them to " f"VERBS above") + + +@pytest.mark.parametrize("argv,verb", [ + (["reload"], "reload"), + (["restart-daemon"], "restart"), +]) +def test_lifecycle_commands_delegate_to_systemctl_user( + argv, verb, stub_daemon, monkeypatch, capsys): + """ + `reload` and `restart-daemon` must ask systemd to do it, not hunt a PID + with pgrep and signal it directly — that pattern-matched a developer's own + running node by accident once, which is why it was replaced. + """ + monkeypatch.setattr(sys, "argv", ["meshbay-node", *argv]) + daemon_mod.main() + + systemctl_calls = dict(stub_daemon)["_systemctl_calls"] + assert systemctl_calls == [["systemctl", "--user", verb, "meshbay-node"]] + assert not capsys.readouterr().err + + +@pytest.mark.parametrize("argv", [["reload"], ["restart-daemon"]]) +def test_lifecycle_commands_report_systemctl_failure( + argv, stub_daemon, monkeypatch, capsys): + """A unit that refuses (not installed, not running) must exit non-zero.""" + import subprocess + + monkeypatch.setattr( + subprocess, "run", + lambda a, **k: subprocess.CompletedProcess( + a, 1, stdout="", stderr="Unit meshbay-node.service not loaded.\n")) + monkeypatch.setattr(sys, "argv", ["meshbay-node", *argv]) + with pytest.raises(SystemExit) as exc: + daemon_mod.main() + assert exc.value.code == 1 + assert "not loaded" in capsys.readouterr().out |