omp-conductor 0.15.3 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/package.json +1 -1
- package/src/cli.ts +7 -2
- package/src/daemon.ts +146 -0
- package/src/gitops.ts +98 -16
- package/src/lifecycle.ts +149 -29
- package/src/omp.ts +34 -0
- package/src/types.ts +1 -0
- package/src/upgrade.ts +283 -52
- package/src/worker.ts +41 -15
package/src/lifecycle.ts
CHANGED
|
@@ -364,9 +364,19 @@ export async function stopDaemon(o: { timeoutMs?: number } = {}): Promise<StopRe
|
|
|
364
364
|
* Same ownership rule as {@link stopDaemon}: when the unit owns the live pid,
|
|
365
365
|
* `systemctl restart` is the *only* path — a failed manager call is terminal,
|
|
366
366
|
* never a fallthrough to raw signals. An *unanswered* ownership query is also
|
|
367
|
-
* terminal: "dbus blipped" is not "no unit".
|
|
368
|
-
*
|
|
369
|
-
* a
|
|
367
|
+
* terminal: "dbus blipped" is not "no unit". A *failed* installed unit is the
|
|
368
|
+
* third non-fallthrough: it must be restored through the manager
|
|
369
|
+
* (`reset-failed` + `start`), because a detached CLI daemon beside a failed
|
|
370
|
+
* unit is exactly the "restarted, but the fleet is still down" lie this
|
|
371
|
+
* module exists to prevent (#376). Falling back to stop+start is reserved for
|
|
372
|
+
* a confirmed hand-started daemon (no systemd, inactive unit, or a unit whose
|
|
373
|
+
* MainPID is someone else).
|
|
374
|
+
*
|
|
375
|
+
* Success is reported only after the service manager is re-probed and proven
|
|
376
|
+
* to own the daemon: the unit's MainPID must equal the daemon record pid AND
|
|
377
|
+
* `/healthz` must answer. The manager call returning is not that proof — for
|
|
378
|
+
* `Type=simple` the unit is active as soon as the process is forked, before
|
|
379
|
+
* the daemon has bound its port.
|
|
370
380
|
*
|
|
371
381
|
* Returns the record of the process that is now answering `/healthz`.
|
|
372
382
|
*/
|
|
@@ -379,6 +389,16 @@ export async function restartDaemon(
|
|
|
379
389
|
throw new Error(ownershipUnknown("restart", ownership.reason));
|
|
380
390
|
}
|
|
381
391
|
|
|
392
|
+
if (ownership.kind === "failed") {
|
|
393
|
+
// An installed unit in failed state: reset the failure record and let
|
|
394
|
+
// systemd start the service. Never fall through to stopDaemon() +
|
|
395
|
+
// startDaemon() — that leaves the unit failed while handing the operator
|
|
396
|
+
// a healthy-looking but unmanaged pid (the 12:17Z incident in #376).
|
|
397
|
+
// Any manager refusal is terminal; so is unproven ownership afterwards.
|
|
398
|
+
const record = await restoreFailedUnit(o.timeoutMs);
|
|
399
|
+
return { previous, record, via: "systemctl" };
|
|
400
|
+
}
|
|
401
|
+
|
|
382
402
|
const unitOwns =
|
|
383
403
|
ownership.kind === "active" && (previous === undefined || previous.pid === ownership.pid);
|
|
384
404
|
|
|
@@ -393,26 +413,95 @@ export async function restartDaemon(
|
|
|
393
413
|
// systemctl restart returns once the new MainPID is up; the pidfile is
|
|
394
414
|
// written by the daemon itself on boot, so wait for that rather than
|
|
395
415
|
// inventing a record from the unit alone.
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
const rec = livingDaemon();
|
|
399
|
-
if (rec !== undefined) {
|
|
400
|
-
const health = await healthCheck(rec.port);
|
|
401
|
-
if (health.ok) return { previous, record: rec, via: "systemctl" };
|
|
402
|
-
}
|
|
403
|
-
if (Date.now() >= deadline) break;
|
|
404
|
-
await sleep(READY_POLL_MS);
|
|
405
|
-
}
|
|
406
|
-
throw new Error(
|
|
407
|
-
`systemctl restart ${SYSTEMD_UNIT} returned, but the daemon never answered /healthz`,
|
|
408
|
-
);
|
|
416
|
+
const record = await waitForOwnedDaemon("restart", o.timeoutMs);
|
|
417
|
+
return { previous, record, via: "systemctl" };
|
|
409
418
|
}
|
|
410
419
|
|
|
420
|
+
// Confirmed unmanaged: no unit, an inactive unit, or a unit whose MainPID
|
|
421
|
+
// is somebody else. The detached CLI daemon is the only path left.
|
|
411
422
|
await stopDaemon({ timeoutMs: o.timeoutMs });
|
|
412
423
|
const record = await startDaemon({ port: o.port ?? previous?.port, project: o.project ?? previous?.project });
|
|
413
424
|
return { previous, record, via: "cli" };
|
|
414
425
|
}
|
|
415
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Restores a failed installed unit through the manager: `reset-failed` then
|
|
429
|
+
* `start`, and proves the result the same way any managed restart is proven
|
|
430
|
+
* (see {@link waitForOwnedDaemon}). Refusal or unproven ownership is terminal
|
|
431
|
+
* — never a fallthrough to the detached CLI daemon.
|
|
432
|
+
*/
|
|
433
|
+
async function restoreFailedUnit(timeoutMs?: number): Promise<DaemonRecord> {
|
|
434
|
+
const reset = systemctl(["reset-failed", SYSTEMD_UNIT]);
|
|
435
|
+
if (!reset.ok) {
|
|
436
|
+
throw new Error(systemctlFailure("reset-failed", reset));
|
|
437
|
+
}
|
|
438
|
+
const started = systemctl(["start", SYSTEMD_UNIT]);
|
|
439
|
+
if (!started.ok) {
|
|
440
|
+
throw new Error(systemctlFailure("start", started));
|
|
441
|
+
}
|
|
442
|
+
return await waitForOwnedDaemon("start", timeoutMs);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Waits for the daemon a manager call was just made to boot, and refuses to
|
|
447
|
+
* report success until the service manager is proven to own it: the unit's
|
|
448
|
+
* MainPID must equal the daemon record pid AND `/healthz` must answer. A unit
|
|
449
|
+
* that went back to `failed` is a confirmed negative and fails fast — waiting
|
|
450
|
+
* cannot un-fail it. Any other unproven state fails at the deadline with a
|
|
451
|
+
* diagnostic that names the mismatch, never a bare "not ready".
|
|
452
|
+
*/
|
|
453
|
+
async function waitForOwnedDaemon(verb: "restart" | "start", timeoutMs?: number): Promise<DaemonRecord> {
|
|
454
|
+
const via = `systemctl ${verb} ${SYSTEMD_UNIT} returned`;
|
|
455
|
+
const inspect = `systemctl status ${SYSTEMD_UNIT}`;
|
|
456
|
+
const deadline = Date.now() + (timeoutMs ?? READY_TIMEOUT_MS);
|
|
457
|
+
for (;;) {
|
|
458
|
+
const rec = livingDaemon();
|
|
459
|
+
const ownership = probeUnit();
|
|
460
|
+
if (rec !== undefined && ownership.kind === "active" && ownership.pid === rec.pid) {
|
|
461
|
+
const health = await healthCheck(rec.port);
|
|
462
|
+
if (health.ok) return rec;
|
|
463
|
+
} else if (ownership.kind === "failed") {
|
|
464
|
+
// A confirmed negative: the start did not take and the unit is failed
|
|
465
|
+
// again. Waiting longer cannot un-fail it.
|
|
466
|
+
throw new Error(
|
|
467
|
+
`${via}, but the unit went back to failed — the daemon is NOT running; check \`${inspect}\``,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
if (Date.now() >= deadline) break;
|
|
471
|
+
await sleep(READY_POLL_MS);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Failed to prove ownership. Name what is wrong rather than a bare timeout,
|
|
475
|
+
// so the operator knows whether to fix the unit or hunt the foreign pid.
|
|
476
|
+
const rec = livingDaemon();
|
|
477
|
+
const ownership = probeUnit();
|
|
478
|
+
if (rec === undefined) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`${via}, but no live daemon record appeared — the daemon did not come back; check \`${inspect}\``,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (ownership.kind === "active") {
|
|
484
|
+
if (ownership.pid === rec.pid) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`${via}, but the daemon never answered /healthz on :${rec.port} — ` +
|
|
487
|
+
`the service manager owns pid ${rec.pid}, but it is not serving; ` +
|
|
488
|
+
`check \`${inspect}\` and ${rec.logFile}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
throw new Error(
|
|
492
|
+
`${via}, but the unit's MainPID (${ownership.pid}) does not match the daemon record pid (${rec.pid}) — ` +
|
|
493
|
+
`the reported pid is NOT owned by the service manager; check \`${inspect}\``,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (ownership.kind === "unknown") {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`${via}, but ownership can no longer be determined (${ownership.reason}) — ` +
|
|
499
|
+
`cannot confirm the daemon is managed; check \`${inspect}\``,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
throw new Error(`${via}, but the unit is not active; check \`${inspect}\``);
|
|
503
|
+
}
|
|
504
|
+
|
|
416
505
|
/**
|
|
417
506
|
* What we know about {@link SYSTEMD_UNIT}.
|
|
418
507
|
*
|
|
@@ -420,14 +509,22 @@ export async function restartDaemon(
|
|
|
420
509
|
* states (`activating`, `deactivating`, `reloading`, `reactivating`) — the
|
|
421
510
|
* pid is still systemd-owned, so a raw SIGTERM would bounce under
|
|
422
511
|
* `Restart=on-failure`.
|
|
423
|
-
* - `
|
|
424
|
-
* no
|
|
512
|
+
* - `failed` — the unit is installed and its last activation failed
|
|
513
|
+
* (ActiveState=failed, no MainPID). systemd still owns this unit's
|
|
514
|
+
* lifecycle, so restoring the daemon must go through the manager
|
|
515
|
+
* (`reset-failed` + `start`); classifying a failed unit as "not supervised
|
|
516
|
+
* here" is how `restart` launched an unmanaged daemon next to a unit that
|
|
517
|
+
* stayed failed (#376).
|
|
518
|
+
* - `inactive` — confirmed not running (MainPID absent/0, ActiveState not
|
|
519
|
+
* `failed`), unit absent, *or* no `systemctl` binary on this host. Safe to
|
|
520
|
+
* treat as "not supervised here".
|
|
425
521
|
* - `unknown` — the manager exists (or we cannot tell it does not) but the
|
|
426
522
|
* query failed: dbus blip, permission, timeout. Must not be collapsed into
|
|
427
523
|
* `inactive` — that is how a unit-owned daemon gets a raw SIGTERM.
|
|
428
524
|
*/
|
|
429
525
|
export type UnitOwnership =
|
|
430
526
|
| { kind: "active"; pid: number }
|
|
527
|
+
| { kind: "failed" }
|
|
431
528
|
| { kind: "inactive" }
|
|
432
529
|
| { kind: "unknown"; reason: string };
|
|
433
530
|
|
|
@@ -454,26 +551,39 @@ export function probeUnit(unit = SYSTEMD_UNIT): UnitOwnership {
|
|
|
454
551
|
// live pid (> 1) means systemd still owns that process — including during
|
|
455
552
|
// `activating` / `deactivating` / `reloading`. Filtering on ActiveState here
|
|
456
553
|
// used to label those transitional states "inactive" and hand the pid to a
|
|
457
|
-
// raw SIGTERM, which is exactly the Restart=on-failure bounce.
|
|
554
|
+
// raw SIGTERM, which is exactly the Restart=on-failure bounce. The one
|
|
555
|
+
// place ActiveState decides is the `failed` classification below: with no
|
|
556
|
+
// MainPID there is no pid to hand to anything, but the unit has not gone
|
|
557
|
+
// away either.
|
|
458
558
|
const lines = ran.stdout
|
|
459
559
|
.split("\n")
|
|
460
560
|
.map((l) => l.trim())
|
|
461
561
|
.filter((l) => l.length > 0);
|
|
462
562
|
let pid: number | undefined;
|
|
563
|
+
let activeState: string | undefined;
|
|
463
564
|
for (const line of lines) {
|
|
464
565
|
if (/^\d+$/.test(line)) {
|
|
465
566
|
const n = Number(line);
|
|
466
567
|
if (Number.isInteger(n) && n > 1) pid = n;
|
|
568
|
+
} else {
|
|
569
|
+
activeState = line;
|
|
467
570
|
}
|
|
468
571
|
}
|
|
469
|
-
if (pid
|
|
470
|
-
|
|
572
|
+
if (pid !== undefined) return { kind: "active", pid };
|
|
573
|
+
// A failed unit is NOT the confirmed negative `inactive` claims to be: the
|
|
574
|
+
// unit is installed and the manager still owns its lifecycle, so restoring
|
|
575
|
+
// the daemon has to go through systemd (`reset-failed` + `start`). Reading
|
|
576
|
+
// `failed` as "not supervised here" is precisely how #376 ended with a
|
|
577
|
+
// healthy-looking but unmanaged pid next to a unit that stayed failed.
|
|
578
|
+
if (activeState === "failed") return { kind: "failed" };
|
|
579
|
+
return { kind: "inactive" };
|
|
471
580
|
}
|
|
472
581
|
|
|
473
582
|
/**
|
|
474
583
|
* The MainPID of an *active* {@link SYSTEMD_UNIT}, or `undefined` when the
|
|
475
|
-
* unit is confirmed inactive
|
|
476
|
-
* Prefer {@link probeUnit} when the caller must distinguish
|
|
584
|
+
* unit is confirmed inactive, failed, or absent, or when ownership could not
|
|
585
|
+
* be determined. Prefer {@link probeUnit} when the caller must distinguish
|
|
586
|
+
* those two.
|
|
477
587
|
*/
|
|
478
588
|
export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
|
|
479
589
|
const ownership = probeUnit(unit);
|
|
@@ -490,8 +600,9 @@ export function systemdMainPid(unit = SYSTEMD_UNIT): number | undefined {
|
|
|
490
600
|
* - `stop` — unit is active and owns `pid` (or there is no pidfile and the
|
|
491
601
|
* unit is the only candidate). Caller MUST go through systemctl; a failed
|
|
492
602
|
* manager call is terminal.
|
|
493
|
-
* - `not-ours` — confirmed inactive/absent unit, no systemd binary, or
|
|
494
|
-
* whose MainPID is someone else. Caller may SIGTERM its own pidfile
|
|
603
|
+
* - `not-ours` — confirmed inactive/failed/absent unit, no systemd binary, or
|
|
604
|
+
* a unit whose MainPID is someone else. Caller may SIGTERM its own pidfile
|
|
605
|
+
* process (a failed unit owns nothing, so signalling its record is safe).
|
|
495
606
|
* - `unknown` — ownership query failed. Caller MUST NOT signal.
|
|
496
607
|
*/
|
|
497
608
|
type SystemdStopDecision =
|
|
@@ -502,7 +613,10 @@ type SystemdStopDecision =
|
|
|
502
613
|
function decideSystemdStop(pid: number | undefined): SystemdStopDecision {
|
|
503
614
|
const ownership = probeUnit();
|
|
504
615
|
if (ownership.kind === "unknown") return { kind: "unknown", reason: ownership.reason };
|
|
505
|
-
|
|
616
|
+
// A failed unit owns no process (MainPID 0 — systemd has already reaped or
|
|
617
|
+
// lost it), so a live record pid is a confirmed "not ours", exactly like an
|
|
618
|
+
// inactive unit. Restoring the unit is restart's job, not stop's.
|
|
619
|
+
if (ownership.kind === "inactive" || ownership.kind === "failed") return { kind: "not-ours" };
|
|
506
620
|
if (pid !== undefined && ownership.pid !== pid) return { kind: "not-ours" };
|
|
507
621
|
return { kind: "stop", mainPid: ownership.pid };
|
|
508
622
|
}
|
|
@@ -533,11 +647,17 @@ async function runSystemdStop(pid: number, timeoutMs?: number): Promise<void> {
|
|
|
533
647
|
}
|
|
534
648
|
}
|
|
535
649
|
|
|
536
|
-
function systemctlFailure(
|
|
650
|
+
function systemctlFailure(
|
|
651
|
+
verb: "stop" | "restart" | "reset-failed" | "start",
|
|
652
|
+
ran: SystemctlResult,
|
|
653
|
+
): string {
|
|
537
654
|
const detail = (ran.stderr.trim() || ran.stdout.trim() || "no output").split("\n")[0] ?? "no output";
|
|
655
|
+
const guard =
|
|
656
|
+
verb === "stop" || verb === "restart"
|
|
657
|
+
? `refusing to signal a unit-owned daemon (that is how Restart=on-failure turns stop into a bounce)`
|
|
658
|
+
: `the unit was NOT restored, so the daemon would be unmanaged`;
|
|
538
659
|
return (
|
|
539
|
-
`systemctl ${verb} ${SYSTEMD_UNIT} failed: ${detail} — ` +
|
|
540
|
-
`refusing to signal a unit-owned daemon (that is how Restart=on-failure turns stop into a bounce); ` +
|
|
660
|
+
`systemctl ${verb} ${SYSTEMD_UNIT} failed: ${detail} — ${guard}; ` +
|
|
541
661
|
`fix the unit or run \`systemctl ${verb} ${SYSTEMD_UNIT}\` yourself`
|
|
542
662
|
);
|
|
543
663
|
}
|
package/src/omp.ts
CHANGED
|
@@ -398,6 +398,15 @@ const DRAIN_GRACE_MS = 2_000;
|
|
|
398
398
|
/** How long a disposed child gets to exit before it is signalled. */
|
|
399
399
|
const DISPOSE_GRACE_MS = 5_000;
|
|
400
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Thrown by {@link createSession} when the pre-spawn admission gate closes: a
|
|
403
|
+
* daemon stop landed while the session socket was binding, so the child was
|
|
404
|
+
* never spawned. The caller maps this to a stopped run, not a failed one —
|
|
405
|
+
* a shutdown must not charge an attempt against a worker it refused to start
|
|
406
|
+
* (#374).
|
|
407
|
+
*/
|
|
408
|
+
export class SessionAdmissionClosedError extends Error {}
|
|
409
|
+
|
|
401
410
|
export interface CreateSessionOptions {
|
|
402
411
|
cwd: string;
|
|
403
412
|
sessionDir?: string;
|
|
@@ -430,6 +439,16 @@ export interface CreateSessionOptions {
|
|
|
430
439
|
* its verb channel to this pid, and a channel accepts nothing until it is bound.
|
|
431
440
|
*/
|
|
432
441
|
onSpawn?: (pid: number) => void;
|
|
442
|
+
/**
|
|
443
|
+
* Pre-spawn admission gate (#374). Consulted once, immediately after the
|
|
444
|
+
* socket bind await and immediately before `Bun.spawn` — the last window a
|
|
445
|
+
* daemon stop can land in before a child exists. A gate that returns false
|
|
446
|
+
* closes the listener, removes the socket, and throws
|
|
447
|
+
* {@link SessionAdmissionClosedError} instead of spawning; the run then
|
|
448
|
+
* settles as stopped rather than adding a worker the shutdown would have to
|
|
449
|
+
* wait for. Absent, the spawn always proceeds.
|
|
450
|
+
*/
|
|
451
|
+
maySpawn?: () => boolean;
|
|
433
452
|
/** Child stderr, line by line. Defaults to the process's own stderr. */
|
|
434
453
|
onChildLog?: (line: string) => void;
|
|
435
454
|
startupTimeoutMs?: number;
|
|
@@ -505,6 +524,21 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
505
524
|
resolve();
|
|
506
525
|
});
|
|
507
526
|
});
|
|
527
|
+
// Pre-spawn admission recheck (#374): SIGTERM/SIGINT can land during the
|
|
528
|
+
// listen await above — after the daemon's own pre-launch checks and before
|
|
529
|
+
// this child exists. A closed gate means no child: close the listener,
|
|
530
|
+
// remove the socket (and, when this side created the directory, the
|
|
531
|
+
// directory itself — the same cleanup the failure paths below run), and
|
|
532
|
+
// signal the caller to settle the run as stopped instead of spawning a
|
|
533
|
+
// worker the shutdown would then have to wait for.
|
|
534
|
+
if (opts.maySpawn?.() === false) {
|
|
535
|
+
server.close();
|
|
536
|
+
rmSync(socketPath, { force: true });
|
|
537
|
+
if (owned) rmSync(socketDir, { recursive: true, force: true });
|
|
538
|
+
throw new SessionAdmissionClosedError(
|
|
539
|
+
"daemon shutdown began while the session socket was binding",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
508
542
|
// The socket is the run's own channel, and the daemon's own uid is the only
|
|
509
543
|
// one that speaks on it.
|
|
510
544
|
chmodSync(socketPath, 0o600);
|
package/src/types.ts
CHANGED