openvibers/openvibe.host Commits

multi-app: a oneshot unit the unitsMatch glob catches is a job, not a service (found in the WS-N task 11 cutover, 2026-09-27). openvibe-tools*.service also matches openvibe-toolsjob.service, the job proof its timer runs (Type=oneshot, inactive between runs): the first ovhost deploy of Tools restarted it, then waited for it to stay active, so the deploy read not ready and so did its rollback (Tools itself was healthy throughout). Units found by the glob are now asked their Type (systemd show gains Type); oneshot ones are named in the plan ('jobs ... not restarted, not waited on') and left alone. test/strategy-in-place.test.js adds such a unit and checks it is not restarted.

OP OpenVibers committed 27 Sept 2026 · 08acfbdf57 · 5 files, +20 −4


Changes

ddiff --git a/lib/cli.js b/lib/cli.js
iindex dd5b73b..d6544d6 100644
---- a/lib/cli.js
++++ b/lib/cli.js
@@@ -239,6 +239,7 @@ async function main(argv, { exec = lib.createSystemExecutor(), out = (s) => proc
if (p.preflight && p.preflight.length) out(` preflight ${p.preflight.join('; ')}`);
out(` restart ${p.restartNeeded ? `yes: ${p.units.join(', ')}${p.socketUnit ? ` (socket ${p.socketUnit} stays up)` : ''}` : p.layout === 'release' && !p.upToDate ? 'no — current switches, the process keeps running' : 'no'}`);
if (p.extraUnits && p.extraUnits.length) out(` units ${p.extraUnits.join(', ')} are not in the inventory but are restarted too (unitsMatch)`);
++ if (p.skippedJobs && p.skippedJobs.length) out(` jobs ${p.skippedJobs.join(', ')} match unitsMatch but are oneshot jobs: not restarted, not waited on`);
if (p.socket) out(` socket ${p.socket.held ? `held by systemd on :${p.socket.port}` : p.socket.held === false ? `NOT held by systemd on :${p.socket.port} — a restart rebinds it (stop, restart the socket, start)` : 'unknown'}${p.socket.changed ? '; the socket unit file changed — a restart rebinds it' : ''}`);
if (p.protectedSessions) {
const s = p.protectedSessions;
ddiff --git a/lib/release-ops.js b/lib/release-ops.js
iindex dd30e3c..80997f3 100644
---- a/lib/release-ops.js
++++ b/lib/release-ops.js
@@@ -134,10 +134,20 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
const codeChanged = changed.filter((f) => !matchesPath(f, svc.noRestartPaths));
// unitsMatch (Tools: openvibe-tools*.service): a unit file on the host that the inventory does not
-- // list is restarted too, as the script did, and named so the inventory can be corrected.
++ // list is restarted too, as the script did, and named so the inventory can be corrected. A oneshot
++ // unit the glob catches is a job, not a service (openvibe-toolsjob.service, the job proof a timer runs;
++ // 2026-09-27 it was restarted and then waited on as if it should stay active, so the deploy and its
++ // rollback both read "not ready"): it is named and left alone.
let extraUnits = [];
++ const skippedJobs = [];
if (svc.unitsMatch) {
-- try { extraUnits = (await systemd.listUnitFiles(exec, svc.unitsMatch)).filter((u) => !svc.units.includes(u) && !svc.workerUnits.includes(u)); } catch { extraUnits = []; }
++ let found = [];
++ try { found = (await systemd.listUnitFiles(exec, svc.unitsMatch)).filter((u) => !svc.units.includes(u) && !svc.workerUnits.includes(u)); } catch { found = []; }
++ for (const u of found) {
++ let type = '';
++ try { type = (await systemd.show(exec, u)).type; } catch { /* unknown: treat as a service */ }
++ if (type === 'oneshot') skippedJobs.push(u); else extraUnits.push(u);
++ }
}
const units = [...svc.units, ...extraUnits];
const restartNeeded = units.length > 0 && (restart || codeChanged.length > 0);
@@@ -186,6 +196,7 @@ async function computePlan(ctx, svc, { mode = 'deploy', to = null, fetch = true,
restartNeeded,
units,
extraUnits,
++ skippedJobs,
socketUnit: svc.socketUnit,
unitDrift,
installUnits: svc.installUnits,
ddiff --git a/lib/systemd.js b/lib/systemd.js
iindex 51c8c15..b17e652 100644
---- a/lib/systemd.js
++++ b/lib/systemd.js
@@@ -8,7 +8,7 @@
const SOCKET_SAFE_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'cat', 'status', 'list-units', 'list-unit-files']);
const ALLOWED_ACTIONS = new Set(['show', 'is-active', 'is-enabled', 'start', 'enable', 'restart', 'reload', 'cat', 'status', 'daemon-reload', 'list-units', 'list-unit-files']);
const READ_ONLY_ACTIONS = ['show', 'is-active', 'is-enabled', 'cat', 'status', 'list-units', 'list-unit-files'];
--const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts', 'TimeoutStopUSec', 'KillSignal'];
++const PROPS = ['LoadState', 'ActiveState', 'SubState', 'MainPID', 'FragmentPath', 'UnitFileState', 'DropInPaths', 'PartOf', 'NRestarts', 'TimeoutStopUSec', 'KillSignal', 'Type'];
class SocketGuardError extends Error {}
@@@ -48,6 +48,7 @@ async function show(exec, unit) {
// drop-ins and systemd's defaults applied; '' when systemctl does not report them.
timeoutStop: props.TimeoutStopUSec || '',
killSignal: props.KillSignal || '',
++ type: props.Type || '', // simple, notify, oneshot, … ('' when not reported)
};
}
ddiff --git a/test/fake-host.js b/test/fake-host.js
iindex 103e5be..8998d77 100644
---- a/test/fake-host.js
++++ b/test/fake-host.js
@@@ -290,7 +290,7 @@ function createFakeHost({ root = true, user = 'root', hostname = 'fake-host', st
switch (action) {
case 'show': {
if (!u) return { code: 0, stdout: 'LoadState=not-found\nActiveState=inactive\nSubState=dead\nMainPID=0\nFragmentPath=\n', stderr: '' };
-- return { code: 0, stdout: [`LoadState=${u.load}`, `ActiveState=${u.active}`, `SubState=${u.sub}`, `MainPID=${u.active === 'active' ? u.mainPid : 0}`, `FragmentPath=${u.fragmentPath}`, `UnitFileState=enabled`, `DropInPaths=${u.dropIns.join(' ')}`, `PartOf=${u.partOf.join(' ')}`, `NRestarts=${u.restarts}`, `TimeoutStopUSec=${u.timeoutStop || '1min 30s'}`, `KillSignal=${u.killSignal || 15}`].join('\n'), stderr: '' };
++ return { code: 0, stdout: [`LoadState=${u.load}`, `ActiveState=${u.active}`, `SubState=${u.sub}`, `MainPID=${u.active === 'active' ? u.mainPid : 0}`, `FragmentPath=${u.fragmentPath}`, `UnitFileState=enabled`, `DropInPaths=${u.dropIns.join(' ')}`, `PartOf=${u.partOf.join(' ')}`, `NRestarts=${u.restarts}`, `TimeoutStopUSec=${u.timeoutStop || '1min 30s'}`, `KillSignal=${u.killSignal || 15}`, `Type=${u.type || 'simple'}`].join('\n'), stderr: '' };
}
case 'restart':
if (!u) return { code: 5, stdout: '', stderr: `Unit ${unit} not found.` };
ddiff --git a/test/strategy-in-place.test.js b/test/strategy-in-place.test.js
iindex 8649969..203082c 100644
---- a/test/strategy-in-place.test.js
++++ b/test/strategy-in-place.test.js
@@@ -27,6 +27,8 @@ runTests([
test('tools: per-app installs skip apps/_shared, preflight runs in each app, every unit restarts, the gateway serves the new sha', async () => {
const host = toolsHost();
host.addUnit('openvibe-tools-docs.service', { runningSha: host.repo.head });
++ // A timer's oneshot job the glob also matches (production's openvibe-toolsjob.service): never restarted or waited on.
++ host.addUnit('openvibe-toolsjob.service', { type: 'oneshot', active: 'inactive', sub: 'dead' });
const to = host.push({ 'apps/img/package.json': JSON.stringify({ name: 'tools-img', version: '1.0.1', dependencies: { express: '^1', 'better-sqlite3': '^1', 'openvibe-contracts': '^1', 'openvibe-sdk': '^1', sharp: '^1' } }), 'apps/_shared/guard/index.js': 'guard2();' }, 'img: sharp');
const r = await host.cli('deploy', 'tools');
assert.strictEqual(r.code, 0, r.out);
@@@ -44,6 +46,7 @@ runTests([
// Every unit, including one on the host the inventory does not list (unitsMatch), named loudly.
assert.deepStrictEqual(host.restarts(), ['openvibe-tools.service', 'openvibe-tools-img.service', 'openvibe-tools-maps.service', 'openvibe-tools-docs.service']);
assert.match(r.out, /openvibe-tools-docs\.service match openvibe-tools\*\.service but are not in the inventory's units/);
++ assert.ok(!host.restarts().includes('openvibe-toolsjob.service'), 'a oneshot job is not restarted');
const rec = await lastRecord(host, 'tools');
assert.strictEqual(rec.result, 'deployed');
assert.strictEqual(rec.strategy, 'multi-app');