




















@@ -66,6 +66,8 @@ import {
6666} from "./chrome.js";
6767import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
686869+const CHROME_TEST_WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
70+6971/**
7072 * Covers the parts of chrome.ts that the mainline chrome.test.ts does
7173 * not exercise: launchOpenClawChrome (with child_process.spawn mocked),
@@ -135,6 +137,94 @@ function mockExpiredLaunchPollingClock(): void {
135137});
136138}
137139140+function linuxProcStatLine(pid: number, startTime: string): string {
141+const fieldsAfterCommand = [
142+"S",
143+"1",
144+"1",
145+"1",
146+"0",
147+"-1",
148+"4194560",
149+"0",
150+"0",
151+"0",
152+"0",
153+"0",
154+"0",
155+"0",
156+"0",
157+"20",
158+"0",
159+"1",
160+"0",
161+startTime,
162+"0",
163+"0",
164+];
165+return `${pid} (chrome) ${fieldsAfterCommand.join(" ")}`;
166+}
167+168+function linuxTcpTableForPort(port: number, inode: string): string {
169+const portHex = port.toString(16).toUpperCase().padStart(4, "0");
170+return [
171+" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode",
172+` 0: 0100007F:${portHex} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 ${inode}`,
173+].join("\n");
174+}
175+176+function mockLinuxManagedChromeOwnership(params: {
177+pid: number;
178+port: number;
179+executablePath: string;
180+userDataDir: string;
181+argvExecutablePath?: string;
182+ownsPort?: boolean;
183+extraArgs?: string[];
184+}) {
185+const ownsPort = params.ownsPort ?? true;
186+const inode = "889001";
187+const argv = [
188+params.argvExecutablePath ?? params.executablePath,
189+`--remote-debugging-port=${params.port}`,
190+`--user-data-dir=${params.userDataDir}`,
191+ ...(params.extraArgs ?? []),
192+];
193+const readFileSync = fs.readFileSync.bind(fs);
194+vi.spyOn(fs, "readFileSync").mockImplementation(((filePath, options) => {
195+const s = String(filePath);
196+if (s === `/proc/${params.pid}/cmdline`) {
197+return Buffer.from(`${argv.join("\0")}\0`);
198+}
199+if (s === `/proc/${params.pid}/stat`) {
200+return linuxProcStatLine(params.pid, "1234567");
201+}
202+if (s === "/proc/net/tcp") {
203+return ownsPort ? linuxTcpTableForPort(params.port, inode) : linuxTcpTableForPort(1, inode);
204+}
205+if (s === "/proc/net/tcp6") {
206+return " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode";
207+}
208+return readFileSync(filePath, options as never);
209+}) as typeof fs.readFileSync);
210+211+const readdirSync = fs.readdirSync.bind(fs);
212+vi.spyOn(fs, "readdirSync").mockImplementation(((dirPath, options) => {
213+if (String(dirPath) === `/proc/${params.pid}/fd`) {
214+return ownsPort ? (["7"] as never) : ([] as never);
215+}
216+return readdirSync(dirPath, options as never);
217+}) as typeof fs.readdirSync);
218+219+const readlinkSync = fs.readlinkSync.bind(fs);
220+vi.spyOn(fs, "readlinkSync").mockImplementation(((linkPath, options) => {
221+if (String(linkPath) === `/proc/${params.pid}/fd/7`) {
222+return `socket:[${inode}]`;
223+}
224+return readlinkSync(linkPath, options as never);
225+}) as typeof fs.readlinkSync);
226+}
227+138228async function withMockChromeCdpServer(params: {
139229wsPath: string;
140230onConnection?: (wss: WebSocketServer) => void;
@@ -154,7 +244,7 @@ async function withMockChromeCdpServer(params: {
154244res.writeHead(404);
155245res.end();
156246});
157-const wss = new WebSocketServer({ noServer: true });
247+const wss = new WebSocketServer({ noServer: true, maxPayload: CHROME_TEST_WS_MAX_PAYLOAD_BYTES });
158248server.on("upgrade", (req, socket, head) => {
159249if (req.url !== params.wsPath) {
160250socket.destroy();
@@ -746,6 +836,202 @@ describe("chrome.ts internal", () => {
746836});
747837});
748838839+it("stops a lock-owned stale managed CDP listener before relaunching", async () => {
840+const originalPlatform = process.platform;
841+const executablePath = path.join(tmpDir, "chrome");
842+await fsp.writeFile(executablePath, "");
843+const existsSync = fs.existsSync.bind(fs);
844+vi.spyOn(fs, "existsSync").mockImplementation((p) => {
845+const s = String(p);
846+if (s.endsWith("Local State") || s.endsWith("Preferences")) {
847+return true;
848+}
849+return existsSync(p);
850+});
851+const portBusy = new Error("Port is already in use.");
852+portBusy.name = "PortInUseError";
853+ensurePortAvailableMock.mockRejectedValueOnce(portBusy).mockResolvedValue(undefined);
854+855+const stalePid = 43210;
856+let staleProcessAlive = true;
857+const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid, signal) => {
858+if (pid !== stalePid) {
859+return true;
860+}
861+if (signal === 0) {
862+if (staleProcessAlive) {
863+return true;
864+}
865+const err = new Error("no such process") as NodeJS.ErrnoException;
866+err.code = "ESRCH";
867+throw err;
868+}
869+if (signal === "SIGTERM") {
870+staleProcessAlive = false;
871+return true;
872+}
873+return true;
874+}) as typeof process.kill);
875+876+const fakeProc = makeFakeProc();
877+spawnMock.mockReturnValue(fakeProc);
878+879+Object.defineProperty(process, "platform", { value: "linux" });
880+try {
881+await withMockChromeCdpServer({
882+wsPath: "/devtools/browser/STALE_OWNER",
883+onConnection: (wss) => {
884+wss.on("connection", (_ws) => {
885+// The stale listener accepts the WebSocket but never answers
886+// Browser.getVersion, matching the recovery gate in chrome.ts.
887+});
888+},
889+run: async (baseUrl) => {
890+const port = Number(new URL(baseUrl).port);
891+const profile = {
892+ ...makeProfile(port),
893+cdpUrl: baseUrl,
894+ executablePath,
895+} as ResolvedBrowserProfile;
896+const userDataDir = resolveOpenClawUserDataDir(profile.name);
897+mockLinuxManagedChromeOwnership({
898+pid: stalePid,
899+ port,
900+ executablePath,
901+ userDataDir,
902+});
903+await fsp.mkdir(userDataDir, { recursive: true });
904+await fsp.writeFile(path.join(userDataDir, "SingletonCookie"), "cookie");
905+await fsp.writeFile(path.join(userDataDir, "SingletonSocket"), "socket");
906+await fsp.symlink(
907+`${os.hostname()}-${stalePid}`,
908+path.join(userDataDir, "SingletonLock"),
909+);
910+911+try {
912+const running = await launchOpenClawChrome(makeResolved(), profile);
913+expect(running.proc).toBe(fakeProc);
914+expect(ensurePortAvailableMock).toHaveBeenCalledTimes(2);
915+expect(killSpy).toHaveBeenCalledWith(stalePid, "SIGTERM");
916+expect(spawnMock).toHaveBeenCalledTimes(1);
917+expect(fs.existsSync(path.join(userDataDir, "SingletonLock"))).toBe(false);
918+expect(fs.existsSync(path.join(userDataDir, "SingletonSocket"))).toBe(false);
919+running.proc.kill?.("SIGTERM");
920+} finally {
921+await fsp.rm(userDataDir, { recursive: true, force: true });
922+}
923+},
924+});
925+} finally {
926+Object.defineProperty(process, "platform", { value: originalPlatform });
927+}
928+});
929+930+it("does not stop a current-host lock pid without managed Chrome ownership proof", async () => {
931+const originalPlatform = process.platform;
932+const executablePath = path.join(tmpDir, "chrome");
933+await fsp.writeFile(executablePath, "");
934+const existsSync = fs.existsSync.bind(fs);
935+vi.spyOn(fs, "existsSync").mockImplementation((p) => {
936+const s = String(p);
937+if (s.endsWith("Local State") || s.endsWith("Preferences")) {
938+return true;
939+}
940+return existsSync(p);
941+});
942+const portBusy = new Error("Port is already in use.");
943+portBusy.name = "PortInUseError";
944+ensurePortAvailableMock.mockRejectedValue(portBusy);
945+946+const killSpy = vi.spyOn(process, "kill").mockImplementation(((pid, signal) => {
947+if ((pid === 43211 || pid === 43212) && signal === 0) {
948+return true;
949+}
950+return true;
951+}) as typeof process.kill);
952+953+Object.defineProperty(process, "platform", { value: "linux" });
954+try {
955+for (const testCase of [
956+{ pid: 43211, ownsPort: false, argvExecutablePath: executablePath },
957+{ pid: 43212, ownsPort: true, argvExecutablePath: path.join(tmpDir, "other-browser") },
958+]) {
959+await withMockChromeCdpServer({
960+wsPath: `/devtools/browser/STALE_NON_OWNER_${testCase.pid}`,
961+onConnection: (wss) => {
962+wss.on("connection", () => {
963+// Keep the WebSocket stale so the only missing proof is process
964+// ownership of the exact managed Chrome launch.
965+});
966+},
967+run: async (baseUrl) => {
968+const port = Number(new URL(baseUrl).port);
969+const profile = {
970+ ...makeProfile(port),
971+cdpUrl: baseUrl,
972+ executablePath,
973+} as ResolvedBrowserProfile;
974+const userDataDir = resolveOpenClawUserDataDir(`${profile.name}-${testCase.pid}`);
975+const profileWithUniqueName = {
976+ ...profile,
977+name: `${profile.name}-${testCase.pid}`,
978+} as ResolvedBrowserProfile;
979+mockLinuxManagedChromeOwnership({
980+pid: testCase.pid,
981+ port,
982+ executablePath,
983+argvExecutablePath: testCase.argvExecutablePath,
984+ userDataDir,
985+ownsPort: testCase.ownsPort,
986+});
987+await fsp.mkdir(userDataDir, { recursive: true });
988+await fsp.symlink(
989+`${os.hostname()}-${testCase.pid}`,
990+path.join(userDataDir, "SingletonLock"),
991+);
992+993+try {
994+await expect(
995+launchOpenClawChrome(makeResolved(), profileWithUniqueName),
996+).rejects.toThrow("Port is already in use.");
997+expect(killSpy).not.toHaveBeenCalledWith(testCase.pid, "SIGTERM");
998+expect(spawnMock).not.toHaveBeenCalled();
999+await expect(
1000+fsp.lstat(path.join(userDataDir, "SingletonLock")),
1001+).resolves.toBeTruthy();
1002+} finally {
1003+await fsp.rm(userDataDir, { recursive: true, force: true });
1004+}
1005+},
1006+});
1007+}
1008+} finally {
1009+Object.defineProperty(process, "platform", { value: originalPlatform });
1010+}
1011+});
1012+1013+it("does not stop a stale CDP listener without current-host profile ownership proof", async () => {
1014+const portBusy = new Error("Port is already in use.");
1015+portBusy.name = "PortInUseError";
1016+ensurePortAvailableMock.mockRejectedValue(portBusy);
1017+const killSpy = vi.spyOn(process, "kill");
1018+1019+const profile = makeProfile(55554);
1020+const userDataDir = resolveOpenClawUserDataDir(profile.name);
1021+await fsp.mkdir(userDataDir, { recursive: true });
1022+await fsp.symlink("remote-host-43210", path.join(userDataDir, "SingletonLock"));
1023+1024+try {
1025+await expect(launchOpenClawChrome(makeResolved(), profile)).rejects.toThrow(
1026+"Port is already in use.",
1027+);
1028+expect(killSpy).not.toHaveBeenCalledWith(43210, "SIGTERM");
1029+expect(spawnMock).not.toHaveBeenCalled();
1030+} finally {
1031+await fsp.rm(userDataDir, { recursive: true, force: true });
1032+}
1033+});
1034+7491035it("throws with stderr hint + sandbox hint when CDP never becomes reachable", async () => {
7501036const originalPlatform = process.platform;
7511037Object.defineProperty(process, "platform", { value: "linux" });
@@ -956,7 +1242,11 @@ describe("chrome.ts internal", () => {
9561242describe("canOpenWebSocket", () => {
9571243it("resolves false when the direct-ws probe cannot connect", async () => {
9581244// Bind a ws server and then close it, so connecting to it fails.
959-const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
1245+const wss = new WebSocketServer({
1246+port: 0,
1247+host: "127.0.0.1",
1248+maxPayload: CHROME_TEST_WS_MAX_PAYLOAD_BYTES,
1249+});
9601250await new Promise<void>((resolve) => {
9611251wss.once("listening", () => resolve());
9621252});
@@ -970,7 +1260,11 @@ describe("chrome.ts internal", () => {
9701260});
97112619721262it("resolves true when the direct-ws handshake succeeds", async () => {
973-const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
1263+const wss = new WebSocketServer({
1264+port: 0,
1265+host: "127.0.0.1",
1266+maxPayload: CHROME_TEST_WS_MAX_PAYLOAD_BYTES,
1267+});
9741268await new Promise<void>((resolve) => {
9751269wss.once("listening", () => resolve());
9761270});
@@ -1006,7 +1300,11 @@ describe("chrome.ts internal", () => {
10061300// Serve /json/version pointing at a port that's not actually
10071301// accepting ws upgrades — the canRunCdpHealthCommand probe will
10081302// fire its 'error' handler during handshake.
1009-const dead = new WebSocketServer({ port: 0, host: "127.0.0.1" });
1303+const dead = new WebSocketServer({
1304+port: 0,
1305+host: "127.0.0.1",
1306+maxPayload: CHROME_TEST_WS_MAX_PAYLOAD_BYTES,
1307+});
10101308await new Promise<void>((resolve) => {
10111309dead.once("listening", () => resolve());
10121310});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。