Skip to content

Commit bf9c362

Browse files
authored
Gateway: stop and restart unmanaged listeners (openclaw#39355)
* Daemon: allow unmanaged gateway lifecycle fallback * Status: fix service summary formatting * Changelog: note unmanaged gateway lifecycle fallback * Tests: cover unmanaged gateway lifecycle fallback * Daemon: split unmanaged restart health checks * Daemon: harden unmanaged gateway signaling * Daemon: reject unmanaged restarts when disabled
1 parent 4062aa5 commit bf9c362

File tree

6 files changed

+569
-34
lines changed

6 files changed

+569
-34
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ Docs: https://docs.openclaw.ai
260260
- Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing `sessionKey` rolls to a new `sessionId` across auto-reply, command, and isolated cron session resolvers, so `AGENTS.md`/`MEMORY.md`/`USER.md` updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm.
261261
- Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman.
262262
- Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running `systemctl --user is-enabled`, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble.
263+
- Gateway/container lifecycle: allow `openclaw gateway stop` to SIGTERM unmanaged gateway listeners and `openclaw gateway restart` to SIGUSR1 a single unmanaged listener when no service manager is installed, so container and supervisor-based deployments are no longer blocked by `service disabled` no-op responses. Fixes #36137. Thanks @vincentkoc.
263264
- Gateway/Windows restart supervision: relaunch task-managed gateways through Scheduled Task with quoted helper-script command paths, distinguish restart-capable supervisors per platform, and stop orphaned Windows gateway children during self-restart. (#38825) Thanks @obviyus.
264265
- Telegram/native topic command routing: resolve forum-topic native commands through the same conversation route as inbound messages so topic `agentId` overrides and bound topic sessions target the active session instead of the default topic-parent session. (#38871) Thanks @obviyus.
265266
- Markdown/assistant image hardening: flatten remote markdown images to plain text across the Control UI, exported HTML, and shared Swift chat while keeping inline `data:image/...` markdown renderable, so model output no longer triggers automatic remote image fetches. (#38895) Thanks @obviyus.

src/cli/daemon-cli/lifecycle-core.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ vi.mock("../../runtime.js", () => ({
4040
}));
4141

4242
let runServiceRestart: typeof import("./lifecycle-core.js").runServiceRestart;
43+
let runServiceStop: typeof import("./lifecycle-core.js").runServiceStop;
4344

4445
describe("runServiceRestart token drift", () => {
4546
beforeAll(async () => {
46-
({ runServiceRestart } = await import("./lifecycle-core.js"));
47+
({ runServiceRestart, runServiceStop } = await import("./lifecycle-core.js"));
4748
});
4849

4950
beforeEach(() => {
@@ -130,4 +131,49 @@ describe("runServiceRestart token drift", () => {
130131
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] };
131132
expect(payload.warnings).toBeUndefined();
132133
});
134+
135+
it("emits stopped when an unmanaged process handles stop", async () => {
136+
service.isLoaded.mockResolvedValue(false);
137+
138+
await runServiceStop({
139+
serviceNoun: "Gateway",
140+
service,
141+
opts: { json: true },
142+
onNotLoaded: async () => ({
143+
result: "stopped",
144+
message: "Gateway stop signal sent to unmanaged process on port 18789: 4200.",
145+
}),
146+
});
147+
148+
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
149+
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
150+
expect(payload.result).toBe("stopped");
151+
expect(payload.message).toContain("unmanaged process");
152+
expect(service.stop).not.toHaveBeenCalled();
153+
});
154+
155+
it("runs restart health checks after an unmanaged restart signal", async () => {
156+
const postRestartCheck = vi.fn(async () => {});
157+
service.isLoaded.mockResolvedValue(false);
158+
159+
await runServiceRestart({
160+
serviceNoun: "Gateway",
161+
service,
162+
renderStartHints: () => [],
163+
opts: { json: true },
164+
onNotLoaded: async () => ({
165+
result: "restarted",
166+
message: "Gateway restart signal sent to unmanaged process on port 18789: 4200.",
167+
}),
168+
postRestartCheck,
169+
});
170+
171+
expect(postRestartCheck).toHaveBeenCalledTimes(1);
172+
expect(service.restart).not.toHaveBeenCalled();
173+
expect(service.readCommand).not.toHaveBeenCalled();
174+
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
175+
const payload = JSON.parse(jsonLine ?? "{}") as { result?: string; message?: string };
176+
expect(payload.result).toBe("restarted");
177+
expect(payload.message).toContain("unmanaged process");
178+
});
133179
});

src/cli/daemon-cli/lifecycle-core.ts

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ type RestartPostCheckContext = {
2828
fail: (message: string, hints?: string[]) => void;
2929
};
3030

31+
type NotLoadedActionResult = {
32+
result: "stopped" | "restarted";
33+
message?: string;
34+
warnings?: string[];
35+
};
36+
37+
type NotLoadedActionContext = {
38+
json: boolean;
39+
stdout: Writable;
40+
fail: (message: string, hints?: string[]) => void;
41+
};
42+
3143
async function maybeAugmentSystemdHints(hints: string[]): Promise<string[]> {
3244
if (process.platform !== "linux") {
3345
return hints;
@@ -200,6 +212,7 @@ export async function runServiceStop(params: {
200212
serviceNoun: string;
201213
service: GatewayService;
202214
opts?: DaemonLifecycleOptions;
215+
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
203216
}) {
204217
const json = Boolean(params.opts?.json);
205218
const { stdout, emit, fail } = createActionIO({ action: "stop", json });
@@ -213,6 +226,25 @@ export async function runServiceStop(params: {
213226
return;
214227
}
215228
if (!loaded) {
229+
try {
230+
const handled = await params.onNotLoaded?.({ json, stdout, fail });
231+
if (handled) {
232+
emit({
233+
ok: true,
234+
result: handled.result,
235+
message: handled.message,
236+
warnings: handled.warnings,
237+
service: buildDaemonServiceSnapshot(params.service, false),
238+
});
239+
if (!json && handled.message) {
240+
defaultRuntime.log(handled.message);
241+
}
242+
return;
243+
}
244+
} catch (err) {
245+
fail(`${params.serviceNoun} stop failed: ${String(err)}`);
246+
return;
247+
}
216248
emit({
217249
ok: true,
218250
result: "not-loaded",
@@ -251,9 +283,12 @@ export async function runServiceRestart(params: {
251283
opts?: DaemonLifecycleOptions;
252284
checkTokenDrift?: boolean;
253285
postRestartCheck?: (ctx: RestartPostCheckContext) => Promise<void>;
286+
onNotLoaded?: (ctx: NotLoadedActionContext) => Promise<NotLoadedActionResult | null>;
254287
}): Promise<boolean> {
255288
const json = Boolean(params.opts?.json);
256289
const { stdout, emit, fail } = createActionIO({ action: "restart", json });
290+
const warnings: string[] = [];
291+
let handledNotLoaded: NotLoadedActionResult | null = null;
257292

258293
const loaded = await resolveServiceLoadedOrFail({
259294
serviceNoun: params.serviceNoun,
@@ -264,19 +299,29 @@ export async function runServiceRestart(params: {
264299
return false;
265300
}
266301
if (!loaded) {
267-
await handleServiceNotLoaded({
268-
serviceNoun: params.serviceNoun,
269-
service: params.service,
270-
loaded,
271-
renderStartHints: params.renderStartHints,
272-
json,
273-
emit,
274-
});
275-
return false;
302+
try {
303+
handledNotLoaded = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null;
304+
} catch (err) {
305+
fail(`${params.serviceNoun} restart failed: ${String(err)}`);
306+
return false;
307+
}
308+
if (!handledNotLoaded) {
309+
await handleServiceNotLoaded({
310+
serviceNoun: params.serviceNoun,
311+
service: params.service,
312+
loaded,
313+
renderStartHints: params.renderStartHints,
314+
json,
315+
emit,
316+
});
317+
return false;
318+
}
319+
if (handledNotLoaded.warnings?.length) {
320+
warnings.push(...handledNotLoaded.warnings);
321+
}
276322
}
277323

278-
const warnings: string[] = [];
279-
if (params.checkTokenDrift) {
324+
if (loaded && params.checkTokenDrift) {
280325
// Check for token drift before restart (service token vs config token)
281326
try {
282327
const command = await params.service.readCommand(process.env);
@@ -309,22 +354,30 @@ export async function runServiceRestart(params: {
309354
}
310355

311356
try {
312-
await params.service.restart({ env: process.env, stdout });
357+
if (loaded) {
358+
await params.service.restart({ env: process.env, stdout });
359+
}
313360
if (params.postRestartCheck) {
314361
await params.postRestartCheck({ json, stdout, warnings, fail });
315362
}
316-
let restarted = true;
317-
try {
318-
restarted = await params.service.isLoaded({ env: process.env });
319-
} catch {
320-
restarted = true;
363+
let restarted = loaded;
364+
if (loaded) {
365+
try {
366+
restarted = await params.service.isLoaded({ env: process.env });
367+
} catch {
368+
restarted = true;
369+
}
321370
}
322371
emit({
323372
ok: true,
324373
result: "restarted",
374+
message: handledNotLoaded?.message,
325375
service: buildDaemonServiceSnapshot(params.service, restarted),
326376
warnings: warnings.length ? warnings : undefined,
327377
});
378+
if (!json && handledNotLoaded?.message) {
379+
defaultRuntime.log(handledNotLoaded.message);
380+
}
328381
return true;
329382
} catch (err) {
330383
const hints = params.renderStartHints();

0 commit comments

Comments
 (0)