Daemon: handle degraded systemd status checks (#39325)

* Daemon: handle degraded systemd status checks

* Changelog: note systemd status handling

* Update src/commands/status.service-summary.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-03-07 20:30:48 -05:00
committed by GitHub
parent c22a4450ee
commit 556a74d259
8 changed files with 211 additions and 55 deletions

View File

@@ -90,6 +90,14 @@ describe("systemd availability", () => {
await expect(isSystemdUserServiceAvailable()).resolves.toBe(false);
});
it("returns true when systemd is degraded but still reachable", async () => {
execFileMock.mockImplementation((_cmd, _args, _opts, cb) => {
cb(createExecFileError("degraded", { stderr: "degraded\nsome-unit.service failed" }), "", "");
});
await expect(isSystemdUserServiceAvailable()).resolves.toBe(true);
});
it("falls back to machine user scope when --user bus is unavailable", async () => {
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {
@@ -631,6 +639,26 @@ describe("systemd service control", () => {
expect(String(write.mock.calls[0]?.[0])).toContain("Stopped systemd service");
});
it("allows stop when systemd status is degraded but available", async () => {
execFileMock
.mockImplementationOnce((_cmd, _args, _opts, cb) =>
cb(
createExecFileError("degraded", { stderr: "degraded\nsome-unit.service failed" }),
"",
"",
),
)
.mockImplementationOnce((_cmd, args, _opts, cb) => {
expect(args).toEqual(["--user", "stop", "openclaw-gateway.service"]);
cb(null, "", "");
});
await stopSystemdService({
stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
env: {},
});
});
it("restarts a profile-specific user unit", async () => {
execFileMock
.mockImplementationOnce((_cmd, _args, _opts, cb) => cb(null, "", ""))
@@ -658,6 +686,26 @@ describe("systemd service control", () => {
).rejects.toThrow("systemctl stop failed: permission denied");
});
it("throws the user-bus error before stop when systemd is unavailable", async () => {
vi.spyOn(os, "userInfo").mockImplementationOnce(() => {
throw new Error("no user info");
});
execFileMock.mockImplementationOnce((_cmd, _args, _opts, cb) => {
cb(
createExecFileError("Failed to connect to bus", { stderr: "Failed to connect to bus" }),
"",
"",
);
});
await expect(
stopSystemdService({
stdout: { write: vi.fn() } as unknown as NodeJS.WritableStream,
env: { USER: "", LOGNAME: "" },
}),
).rejects.toThrow("systemctl --user unavailable: Failed to connect to bus");
});
it("targets the sudo caller's user scope when SUDO_USER is set", async () => {
execFileMock
.mockImplementationOnce((_cmd, args, _opts, cb) => {

View File

@@ -306,6 +306,19 @@ function isSystemctlBusUnavailable(detail: string): boolean {
);
}
function isSystemdUserScopeUnavailable(detail: string): boolean {
if (!detail) {
return false;
}
const normalized = detail.toLowerCase();
return (
isSystemctlMissing(normalized) ||
isSystemctlBusUnavailable(normalized) ||
normalized.includes("not been booted") ||
normalized.includes("not supported")
);
}
function isGenericSystemctlIsEnabledFailure(detail: string): boolean {
if (!detail) {
return false;
@@ -409,26 +422,11 @@ export async function isSystemdUserServiceAvailable(
if (res.code === 0) {
return true;
}
const detail = `${res.stderr} ${res.stdout}`.toLowerCase();
const detail = `${res.stderr} ${res.stdout}`.trim();
if (!detail) {
return false;
}
if (detail.includes("not found")) {
return false;
}
if (detail.includes("failed to connect")) {
return false;
}
if (detail.includes("not been booted")) {
return false;
}
if (detail.includes("no such file or directory")) {
return false;
}
if (detail.includes("not supported")) {
return false;
}
return false;
return !isSystemdUserScopeUnavailable(detail);
}
async function assertSystemdAvailable(env: GatewayServiceEnv = process.env as GatewayServiceEnv) {
@@ -440,6 +438,12 @@ async function assertSystemdAvailable(env: GatewayServiceEnv = process.env as Ga
if (isSystemctlMissing(detail)) {
throw new Error("systemctl not available; systemd user services are required on Linux.");
}
if (!detail) {
throw new Error("systemctl --user unavailable: unknown error");
}
if (!isSystemdUserScopeUnavailable(detail)) {
return;
}
throw new Error(`systemctl --user unavailable: ${detail || "unknown error"}`.trim());
}