


























@@ -55,6 +55,10 @@ const launchdRestartHandoffState = vi.hoisted(() => ({
5555const cleanStaleGatewayProcessesSync = vi.hoisted(() =>
5656vi.fn<(port?: number) => number[]>(() => []),
5757);
58+const inspectPortUsage = vi.hoisted(() =>
59+vi.fn(async () => ({ port: 18789, status: "free", listeners: [], hints: [] })),
60+);
61+const formatPortDiagnostics = vi.hoisted(() => vi.fn(() => ["Port 18789 is already in use."]));
5862const defaultProgramArguments = ["node", "-e", "process.exit(0)"];
59636064function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
@@ -218,6 +222,11 @@ vi.mock("../infra/restart-stale-pids.js", () => ({
218222cleanStaleGatewayProcessesSync: (port?: number) => cleanStaleGatewayProcessesSync(port),
219223}));
220224225+vi.mock("../infra/ports.js", () => ({
226+ inspectPortUsage,
227+ formatPortDiagnostics,
228+}));
229+221230vi.mock("node:fs/promises", async () => {
222231const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
223232const wrapped = {
@@ -307,6 +316,10 @@ beforeEach(() => {
307316state.fileWrites.length = 0;
308317cleanStaleGatewayProcessesSync.mockReset();
309318cleanStaleGatewayProcessesSync.mockReturnValue([]);
319+inspectPortUsage.mockReset();
320+inspectPortUsage.mockResolvedValue({ port: 18789, status: "free", listeners: [], hints: [] });
321+formatPortDiagnostics.mockReset();
322+formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]);
310323launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReset();
311324launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(false);
312325launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset();
@@ -660,6 +673,67 @@ describe("launchd install", () => {
660673expect(output).toContain("Stopped LaunchAgent");
661674});
662675676+it("verifies the configured gateway port is released before reporting stop success", async () => {
677+const env = {
678+ ...createDefaultLaunchdEnv(),
679+OPENCLAW_GATEWAY_PORT: "19003",
680+};
681+const stdout = new PassThrough();
682+let output = "";
683+stdout.on("data", (chunk: Buffer) => {
684+output += chunk.toString();
685+});
686+687+await stopLaunchAgent({ env, stdout });
688+689+expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19003);
690+expect(inspectPortUsage).toHaveBeenCalledWith(19003);
691+expect(output).toContain("Stopped LaunchAgent");
692+});
693+694+it("resolves the stop postcondition port from the stored LaunchAgent environment", async () => {
695+const env = createDefaultLaunchdEnv();
696+await installLaunchAgent({
697+ env,
698+stdout: new PassThrough(),
699+programArguments: defaultProgramArguments,
700+environment: { OPENCLAW_GATEWAY_PORT: "19006" },
701+});
702+state.launchctlCalls.length = 0;
703+704+await stopLaunchAgent({ env, stdout: new PassThrough() });
705+706+expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19006);
707+expect(inspectPortUsage).toHaveBeenCalledWith(19006);
708+});
709+710+it("fails stop when the verified gateway port remains busy after cleanup", async () => {
711+const env = {
712+ ...createDefaultLaunchdEnv(),
713+OPENCLAW_GATEWAY_PORT: "19004",
714+};
715+const stdout = new PassThrough();
716+let output = "";
717+stdout.on("data", (chunk: Buffer) => {
718+output += chunk.toString();
719+});
720+inspectPortUsage.mockResolvedValue({
721+port: 19004,
722+status: "busy",
723+listeners: [],
724+hints: [],
725+});
726+formatPortDiagnostics.mockReturnValue(["Port 19004 is held by pid 4242."]);
727+728+await expect(stopLaunchAgent({ env, stdout })).rejects.toThrow(
729+"gateway port 19004 is still busy after LaunchAgent stop\nPort 19004 is held by pid 4242.",
730+);
731+732+expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19004);
733+expect(inspectPortUsage).toHaveBeenCalledWith(19004);
734+expect(output).not.toContain("Stopped LaunchAgent");
735+});
736+663737it("stops LaunchAgent with disable+stop when --disable is passed", async () => {
664738const env = createDefaultLaunchdEnv();
665739const stdout = new PassThrough();
@@ -680,6 +754,24 @@ describe("launchd install", () => {
680754expect(output).toContain("Stopped LaunchAgent");
681755});
682756757+it("verifies the configured gateway port is released before reporting disable stop success", async () => {
758+const env = {
759+ ...createDefaultLaunchdEnv(),
760+OPENCLAW_GATEWAY_PORT: "19005",
761+};
762+const stdout = new PassThrough();
763+let output = "";
764+stdout.on("data", (chunk: Buffer) => {
765+output += chunk.toString();
766+});
767+768+await stopLaunchAgent({ env, stdout, disable: true });
769+770+expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19005);
771+expect(inspectPortUsage).toHaveBeenCalledWith(19005);
772+expect(output).toContain("Stopped LaunchAgent");
773+});
774+683775it("treats already-unloaded services as successfully stopped without bootout fallback (--disable)", async () => {
684776const env = createDefaultLaunchdEnv();
685777const stdout = new PassThrough();
@@ -740,6 +832,34 @@ describe("launchd install", () => {
740832expect(output).toContain("used bootout fallback");
741833});
742834835+it("does not report degraded stop success when fallback cleanup leaves the port busy", async () => {
836+const env = {
837+ ...createDefaultLaunchdEnv(),
838+OPENCLAW_GATEWAY_PORT: "19008",
839+};
840+const stdout = new PassThrough();
841+let output = "";
842+state.disableError = "Operation not permitted";
843+stdout.on("data", (chunk: Buffer) => {
844+output += chunk.toString();
845+});
846+inspectPortUsage.mockResolvedValue({
847+port: 19008,
848+status: "busy",
849+listeners: [],
850+hints: [],
851+});
852+formatPortDiagnostics.mockReturnValue(["Port 19008 is held by pid 4242."]);
853+854+await expect(stopLaunchAgent({ env, stdout, disable: true })).rejects.toThrow(
855+"gateway port 19008 is still busy after LaunchAgent stop\nPort 19008 is held by pid 4242.",
856+);
857+858+expect(launchctlCommandNames()).toContain("bootout");
859+expect(output).toContain("used bootout fallback");
860+expect(output).not.toContain("Stopped LaunchAgent");
861+});
862+743863it("falls back to bootout when stop does not fully stop the service (--disable)", async () => {
744864const env = createDefaultLaunchdEnv();
745865const stdout = new PassThrough();
@@ -885,6 +1005,52 @@ describe("launchd install", () => {
8851005expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19001);
8861006});
88710071008+it("uses the stored LaunchAgent environment port for restart stale cleanup", async () => {
1009+const env = createDefaultLaunchdEnv();
1010+await installLaunchAgent({
1011+ env,
1012+stdout: new PassThrough(),
1013+programArguments: defaultProgramArguments,
1014+environment: { OPENCLAW_GATEWAY_PORT: "19007" },
1015+});
1016+state.launchctlCalls.length = 0;
1017+1018+await restartLaunchAgent({
1019+ env,
1020+stdout: new PassThrough(),
1021+});
1022+1023+expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19007);
1024+expect(inspectPortUsage).toHaveBeenCalledWith(19007);
1025+});
1026+1027+it("fails restart before kickstart when the configured gateway port remains busy", async () => {
1028+const env = {
1029+ ...createDefaultLaunchdEnv(),
1030+OPENCLAW_GATEWAY_PORT: "19002",
1031+};
1032+inspectPortUsage.mockResolvedValue({
1033+port: 19002,
1034+status: "busy",
1035+listeners: [],
1036+hints: [],
1037+});
1038+formatPortDiagnostics.mockReturnValue(["Port 19002 is held by pid 4242."]);
1039+1040+await expect(
1041+restartLaunchAgent({
1042+ env,
1043+stdout: new PassThrough(),
1044+}),
1045+).rejects.toThrow(
1046+"gateway port 19002 is still busy before LaunchAgent restart\nPort 19002 is held by pid 4242.",
1047+);
1048+1049+expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19002);
1050+expect(inspectPortUsage).toHaveBeenCalledWith(19002);
1051+expect(launchctlCommandNames()).not.toContain("kickstart");
1052+});
1053+8881054it("skips stale cleanup when no explicit launch agent port can be resolved", async () => {
8891055const env = createDefaultLaunchdEnv();
8901056state.files.clear();
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。