Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/camoufoxnative/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
43 changes: 43 additions & 0 deletions internal/camoufoxnative/process_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ package camoufoxnative
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
)

Expand Down Expand Up @@ -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)
}
6 changes: 6 additions & 0 deletions internal/camoufoxnative/process_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
6 changes: 6 additions & 0 deletions internal/camoufoxnative/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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+"*")
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions internal/camoufoxnative/profile_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}