From 7fe6016928209c626b937804765e9aed8c430941 Mon Sep 17 00:00:00 2001 From: Ryo Kusnadi Date: Wed, 4 Nov 2026 07:01:58 +0700 Subject: [PATCH] =?UTF-8?q?fix(camoufoxnative):=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=97=B6=E7=BB=93=E6=9D=9F=E9=81=97=E7=95=99=20Camoufox=20?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E7=BB=84=E5=86=8D=E5=88=A0=E9=99=A4=20profil?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ryo Kusnadi --- internal/camoufoxnative/launcher.go | 6 ++ internal/camoufoxnative/process_other.go | 43 +++++++++++++ internal/camoufoxnative/process_windows.go | 6 ++ internal/camoufoxnative/profile.go | 6 ++ internal/camoufoxnative/profile_test.go | 72 ++++++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 internal/camoufoxnative/profile_test.go diff --git a/internal/camoufoxnative/launcher.go b/internal/camoufoxnative/launcher.go index 234574ee..336167c4 100644 --- a/internal/camoufoxnative/launcher.go +++ b/internal/camoufoxnative/launcher.go @@ -173,6 +173,12 @@ func launchBrowser(ctx context.Context, options Options, config map[string]any) _ = removeProfile(profile, profileLock) return nil, "", fmt.Errorf("启动 Camoufox: %w", err) } + if err := recordBrowserProcess(profile, command.Process.Pid); err != nil { + _ = command.Process.Kill() + _ = command.Wait() + _ = removeProfile(profile, profileLock) + return nil, "", fmt.Errorf("记录 Camoufox 进程: %w", err) + } tree, err := attachBrowserProcess(command) if err != nil { _ = command.Process.Kill() diff --git a/internal/camoufoxnative/process_other.go b/internal/camoufoxnative/process_other.go index 1b1f7237..6e2a7c67 100644 --- a/internal/camoufoxnative/process_other.go +++ b/internal/camoufoxnative/process_other.go @@ -5,8 +5,12 @@ package camoufoxnative import ( "context" "errors" + "fmt" "os" "os/exec" + "path/filepath" + "strconv" + "strings" "syscall" ) @@ -41,3 +45,42 @@ func terminateBrowserProcess(ctx context.Context, process *browserProcess) error } return err } + +// recordBrowserProcess 把 Camoufox 进程组号写入 profile,供服务异常退出后的下次启动回收 +func recordBrowserProcess(profile string, pid int) error { + return os.WriteFile(filepath.Join(profile, profilePIDName), []byte(strconv.Itoa(pid)), 0o600) +} + +// terminateStaleBrowser 结束 profile 记录的遗留 Camoufox 进程组,进程已不存在或不属于该 profile 时不做任何事 +func terminateStaleBrowser(profile string) error { + data, err := os.ReadFile(filepath.Join(profile, profilePIDName)) + if err != nil { + return nil + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil || pid <= 1 { + return nil + } + output, err := exec.Command("ps", "-ww", "-o", "pgid=,args=", "-p", strconv.Itoa(pid)).Output() + if err != nil || !isProfileBrowser(string(output), pid, profile) { + return nil + } + err = syscall.Kill(-pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return nil + } + if err != nil { + return fmt.Errorf("结束遗留 Camoufox 进程组 %d: %w", pid, err) + } + return nil +} + +// isProfileBrowser 判断 ps 输出的进程是否为以 profile 启动且自成进程组的 Camoufox +func isProfileBrowser(line string, pid int, profile string) bool { + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != strconv.Itoa(pid) { + return false + } + args := strings.TrimSpace(strings.TrimSpace(line)[len(fields[0]):]) + return strings.HasSuffix(args, " -profile "+profile) +} diff --git a/internal/camoufoxnative/process_windows.go b/internal/camoufoxnative/process_windows.go index c7623403..092594f6 100644 --- a/internal/camoufoxnative/process_windows.go +++ b/internal/camoufoxnative/process_windows.go @@ -126,3 +126,9 @@ func terminateBrowserProcess(ctx context.Context, process *browserProcess) error func releaseBrowserTree(tree browserTree) { windows.CloseHandle(windows.Handle(tree)) } + +// recordBrowserProcess 在 Windows 由 Job 回收进程,不需要记录 +func recordBrowserProcess(string, int) error { return nil } + +// terminateStaleBrowser 在 Windows 由 Job 回收进程,不需要处理遗留进程 +func terminateStaleBrowser(string) error { return nil } diff --git a/internal/camoufoxnative/profile.go b/internal/camoufoxnative/profile.go index 413556cb..384b2128 100644 --- a/internal/camoufoxnative/profile.go +++ b/internal/camoufoxnative/profile.go @@ -17,6 +17,9 @@ const profilePrefix = "aistudio-camoufox-" // profileLockName 为 profile 内由创建进程持有的锁文件 const profileLockName = ".aistudio2api-profile.lock" +// profilePIDName 为 profile 内记录 Camoufox 进程组号的文件 +const profilePIDName = ".aistudio2api-profile.pid" + // createProfile 创建临时 profile 并在浏览器生命周期内持有其锁文件 func createProfile() (string, *flock.Flock, error) { profile, err := os.MkdirTemp("", profilePrefix+"*") @@ -75,6 +78,9 @@ func RemoveStaleProfiles() (int, error) { if err != nil || !locked { continue } + if err := terminateStaleBrowser(profile); err != nil { + removeErrors = append(removeErrors, err) + } if err := removeProfile(profile, lock); err != nil { removeErrors = append(removeErrors, fmt.Errorf("删除遗留 Camoufox profile %s: %w", profile, err)) continue diff --git a/internal/camoufoxnative/profile_test.go b/internal/camoufoxnative/profile_test.go new file mode 100644 index 00000000..4daaf4d5 --- /dev/null +++ b/internal/camoufoxnative/profile_test.go @@ -0,0 +1,72 @@ +//go:build !windows + +package camoufoxnative + +import ( + "os" + "path/filepath" + "testing" +) + +// TestIsProfileBrowser 覆盖遗留进程只有在自成进程组且以该 profile 启动时才算匹配 +func TestIsProfileBrowser(t *testing.T) { + profile := "/tmp/aistudio-camoufox-123" + cases := []struct { + name string + line string + want bool + }{ + {name: "match", line: " 61513 camoufox --remote-debugging-port=0 -no-remote -headless -profile /tmp/aistudio-camoufox-123\n", want: true}, + {name: "other profile", line: "61513 camoufox -headless -profile /tmp/aistudio-camoufox-999", want: false}, + {name: "profile prefix only", line: "61513 camoufox -profile /tmp/aistudio-camoufox-1234", want: false}, + {name: "not group leader", line: "61600 camoufox -profile /tmp/aistudio-camoufox-123", want: false}, + {name: "no args", line: "61513", want: false}, + {name: "empty", line: "", want: false}, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + if got := isProfileBrowser(item.line, 61513, profile); got != item.want { + t.Fatalf("isProfileBrowser() = %v, want %v", got, item.want) + } + }) + } +} + +// TestRemoveStaleProfilesWithRecordedProcess 覆盖记录了已不存在进程的遗留 profile 被删除且不影响其他进程 +func TestRemoveStaleProfilesWithRecordedProcess(t *testing.T) { + root := t.TempDir() + t.Setenv("TMPDIR", root) + profile := filepath.Join(root, profilePrefix+"stale") + if err := os.Mkdir(profile, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(profile, profileLockName), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := recordBrowserProcess(profile, 2147483646); err != nil { + t.Fatal(err) + } + removed, err := RemoveStaleProfiles() + if err != nil || removed != 1 { + t.Fatalf("RemoveStaleProfiles() = %d, %v, want 1, nil", removed, err) + } + if _, err := os.Stat(profile); !os.IsNotExist(err) { + t.Fatalf("profile still exists: %v", err) + } +} + +// TestTerminateStaleBrowserIgnoresBadRecord 覆盖缺失、非数字和系统进程号的记录不触发任何结束操作 +func TestTerminateStaleBrowserIgnoresBadRecord(t *testing.T) { + for _, content := range []string{"", "abc", "0", "1", "-5"} { + profile := t.TempDir() + if err := os.WriteFile(filepath.Join(profile, profilePIDName), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + if err := terminateStaleBrowser(profile); err != nil { + t.Fatalf("content %q: %v", content, err) + } + } + if err := terminateStaleBrowser(t.TempDir()); err != nil { + t.Fatal(err) + } +}