onesvm-browser-server/server/internal/dock/browser_adapters.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

204 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// browser_adapters.go:CDP 浏览器类适配器(lightpanda / headless-shell 共用)。
//
// 槽位纪律(plan-final §2.5 实测锚点):lightpanda 4 槽(2.13 jobs/s)、
// headless-shell 槽=1(0.81 jobs/s FIFO 零拒绝)。shell 空闲回收计数:
// 连续空闲满 idleTTL 由 Health/巡检触发「回收标记」(本版不做 Swarm scale——
// TODO 部署轮做:scale 0→1 拉起 + 10min 空闲回收,design §A3)。
package dock
import (
"context"
"sync"
"sync/atomic"
"time"
"onesvm.com/onesvm/browser-server/internal/contract"
)
// 槽位常量(plan-final §2.5)。
const (
pandaSlots = 4
shellSlots = 1
shellIdleTTL = 10 * time.Minute // 空闲回收阈值(design §A3;本版仅计数)
)
// cdpBrowserAdapter CDP 浏览器适配器公共骨架(Execute 由具体实例注入)。
type cdpBrowserAdapter struct {
name string // lightpanda | headless-shell
host string // "host:9222"(CDP 调试端点)
sem chan struct{}
execFn func(ctx context.Context, host, url string) (*contract.RawResult, *contract.ErrBody)
startupMs int64 // Init 探活耗时
healthy atomic.Bool
lastErr string
errMu sync.Mutex
recycles atomic.Int64 // 空闲回收计数(本版仅计数,不做 scale)
lastActiv atomic.Int64 // unix 秒;shell 空闲回收计时
startedAt time.Time
}
// markHealth 线程安全健康位。
func (a *cdpBrowserAdapter) markHealth(ok bool, msg string) {
a.errMu.Lock()
a.healthy.Store(ok)
a.lastErr = msg
a.errMu.Unlock()
}
// snapshotHealth 读健康位与消息。
func (a *cdpBrowserAdapter) snapshotHealth() (bool, string) {
a.errMu.Lock()
defer a.errMu.Unlock()
return a.healthy.Load(), a.lastErr
}
// Capabilities render 分级:lightpanda=light、headless-shell=full。
func (a *cdpBrowserAdapter) Capabilities() contract.Caps {
render := contract.RenderLight
if a.name == "headless-shell" {
render = contract.RenderFull
}
slots := pandaSlots
if a.name == "headless-shell" {
slots = shellSlots
}
return contract.Caps{
Intents: []string{"read"},
Render: render,
Regions: []string{contract.RegionDomestic, contract.RegionOverseas},
MaxConcurrent: slots,
ProxyRequired: true, // 代理由引擎容器 env/flags 配(bench compose 同构),适配器不设 CDP 代理
Formats: []string{"markdown", "text"},
}
}
// Init 探活:GET /json/version 计 startup_ms(cdp_fetch.mjs pickWs 第一步)。
func (a *cdpBrowserAdapter) Init(ctx context.Context) error {
t0 := time.Now()
ok, msg := cdpProbe(ctx, a.host)
a.startupMs = time.Since(t0).Milliseconds()
a.startedAt = time.Now()
a.markHealth(ok, msg)
return nil // 探活失败不阻塞启动;Health() 摘除走降级
}
// Health 健康上报(RSS 进程内无法读远端,上报 0 并注明;协议字段在位)。
func (a *cdpBrowserAdapter) Health() contract.Health {
ok, msg := a.snapshotHealth()
slots := pandaSlots
if a.name == "headless-shell" {
slots = shellSlots
// shell 空闲回收计数:超 TTL 标记回收(本版仅记录 + TODO Swarm scale)。
if ok && time.Since(a.lastActive()) > shellIdleTTL {
msg = msg + "; idle>10min(回收标记,Swarm scale 回收属部署轮 TODO)"
}
}
return contract.Health{
OK: ok,
RSSBytes: 0, // 进程内无法读远端引擎 RSS,恒 0(TODO 部署轮接 cgroup 采集)
StartupMs: a.startupMs,
SlotsFree: slots - a.inFlight(),
Details: msg,
}
}
// inFlight 在途数(信号量占用)。
func (a *cdpBrowserAdapter) inFlight() int {
if a.sem == nil {
return 0
}
return len(a.sem)
}
// sem 槽位(由构造器赋值;放结构外字段避免骨架循环依赖)。
// cdpBrowserAdapter 内嵌槽位字段。
// (Go 结构体无继承,这里直接加字段。)
// lastActive 最近活动时间。
func (a *cdpBrowserAdapter) lastActive() time.Time {
return time.Unix(a.lastActiv.Load(), 0)
}
// touchActive 标记活动。
func (a *cdpBrowserAdapter) touchActive() { a.lastActiv.Store(time.Now().Unix()) }
// Teardown 停新活 + 关闭在途(等待在途清零,最多 grace 5s)。
func (a *cdpBrowserAdapter) Teardown(ctx context.Context) error {
a.markHealth(false, "teardown")
deadline := time.Now().Add(5 * time.Second)
for a.inFlight() > 0 && time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
}
}
return nil
}
// executeCommon 浏览器适配器 Execute 公共路径:槽位 → cdpFetchPage → 活动标记。
func (a *cdpBrowserAdapter) executeCommon(ctx context.Context, job contract.JobEnvelope) (*contract.RawResult, *contract.ErrBody) {
if job.Read == nil {
return nil, &contract.ErrBody{Code: contract.CodeUpstream, Message: "read 任务缺 ReadInput"}
}
select {
case a.sem <- struct{}{}:
defer func() { <-a.sem }()
case <-ctx.Done():
return nil, &contract.ErrBody{Code: contract.CodeTimeout, Message: a.name + " 槽位等待取消"}
}
a.touchActive()
res, eb := a.execFn(ctx, a.host, job.Read.URL)
if eb != nil {
a.markHealth(eb.Code == contract.CodeUpstream, eb.Message)
return nil, eb
}
a.markHealth(true, "")
if res.Engine == "cdp" {
res.Engine = a.name
}
res.Extra["recycles_total"] = a.recycles.Load()
return res, nil
}
// LightpandaAdapter 轻渲染适配器(render=light,4 槽)。
type LightpandaAdapter struct{ *cdpBrowserAdapter }
// NewLightpanda 构造。host 形如 "lightpanda:9222"。
func NewLightpanda(host string) *LightpandaAdapter {
core := &cdpBrowserAdapter{name: "lightpanda", host: host, sem: make(chan struct{}, pandaSlots)}
core.execFn = cdpFetchPage
core.healthy.Store(true)
return &LightpandaAdapter{cdpBrowserAdapter: core}
}
// Execute 精读执行。
func (l *LightpandaAdapter) Execute(ctx context.Context, job contract.JobEnvelope) (*contract.RawResult, *contract.ErrBody) {
return l.executeCommon(ctx, job)
}
// HeadlessShellAdapter 保真适配器(render=full,槽=1)。
type HeadlessShellAdapter struct{ *cdpBrowserAdapter }
// NewHeadlessShell 构造。host 形如 "headless-shell:9222"。
func NewHeadlessShell(host string) *HeadlessShellAdapter {
core := &cdpBrowserAdapter{name: "headless-shell", host: host, sem: make(chan struct{}, shellSlots)}
core.execFn = cdpFetchPage
core.healthy.Store(true)
return &HeadlessShellAdapter{cdpBrowserAdapter: core}
}
// Execute 精读执行。
func (h *HeadlessShellAdapter) Execute(ctx context.Context, job contract.JobEnvelope) (*contract.RawResult, *contract.ErrBody) {
return h.executeCommon(ctx, job)
}
// MarkRecycle 空闲回收计数 +1(调度器巡检触发时调用;Swarm scale 属部署轮)。
func (h *HeadlessShellAdapter) MarkRecycle() { h.recycles.Add(1) }
// Name 适配器展示名(registry 键;内嵌骨架透出)。
func (l *LightpandaAdapter) Name() string { return l.name }
// Name 适配器展示名(registry 键;内嵌骨架透出)。
func (h *HeadlessShellAdapter) Name() string { return h.name }