package dualrun import ( "bytes" "context" "embed" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strconv" "time" ) //go:embed oracle_probe.php var oracleAssets embed.FS // OracleRunner 负责"起一个 PHP 进程、真跑一次结课、把观测值 JSON 带回来"。 // // 为什么用子进程而不是同进程调用: // - legacy 是 PHP 应用,无法在 Go 进程内调用; // - **每次进程 = 每次新连接**,天然满足"每个 case 新连接"这条硬约束 // (Q5:MEMORY 引擎临时表的行不参与回滚,靠 ROLLBACK 清不掉); // - 进程隔离后,PHP 侧的 notice/异常不会污染 Go 侧状态。 type OracleRunner struct { cfg Config // probePath 是内嵌探针释放到磁盘后的路径(每轮 runner 一个,避免并发写)。 probePath string } // NewOracleRunner 把内嵌的 PHP 探针释放到临时目录。 func NewOracleRunner(cfg Config) (*OracleRunner, error) { dir, err := os.MkdirTemp("", "ac15-oracle-*") if err != nil { return nil, err } src, err := oracleAssets.ReadFile("oracle_probe.php") if err != nil { return nil, err } path := filepath.Join(dir, "oracle_probe.php") if err := os.WriteFile(path, src, 0o600); err != nil { return nil, err } return &OracleRunner{cfg: cfg, probePath: path}, nil } // Close 清理释放的探针。 func (o *OracleRunner) Close() { if o.probePath != "" { _ = os.RemoveAll(filepath.Dir(o.probePath)) } } // Run 执行一条 case 的 oracle 侧观测。 // // 入参:ctx;c 待跑用例;frozenAt 冻结点(会被截断到整秒)。 // 出参:OracleResult(含路径证据与"异常前写入值");error 仅表示"进程/解析层面失败"。 func (o *OracleRunner) Run(ctx context.Context, c Case, frozenAt time.Time) (*OracleResult, error) { mut, err := json.Marshal(c.Mutations) if err != nil { return nil, err } cmd := exec.CommandContext(ctx, o.cfg.PHPBinary, "-d", "date.timezone=Asia/Shanghai", "-d", "opcache.enable_cli=0", "-d", "display_errors=stderr", "-d", "memory_limit=1024M", o.probePath, ) cmd.Env = append(os.Environ(), "AC15_LEGACY_REPO="+o.cfg.LegacyRepo, "AC15_DB_HOST="+o.cfg.Host, "AC15_DB_PORT="+strconv.Itoa(o.cfg.Port), "AC15_DB_NAME="+o.cfg.LegacyDBName, "AC15_DB_USER="+o.cfg.User, "AC15_DB_PASS="+o.cfg.Pass, "AC15_RECORD_ID="+c.RecordID, "AC15_IS_SUPPLEMENT="+boolEnv(c.IsSupplement), "AC15_FROZEN_EPOCH="+strconv.FormatInt(TruncateToSecond(frozenAt).Unix(), 10), "AC15_MUTATIONS="+string(mut), ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("php 进程失败:%v;stderr=%s", err, truncate(stderr.String(), 800)) } raw := bytes.TrimSpace(stdout.Bytes()) // 探针以 ##AC15_JSON_BEGIN## 标记 JSON 起点;老输出没有标记时回退到首个 '{'。 // (不能用"最后一个 '{'":JSON 内部有嵌套对象,会切在中间) const marker = "##AC15_JSON_BEGIN##" if i := bytes.Index(raw, []byte(marker)); i >= 0 { raw = bytes.TrimSpace(raw[i+len(marker):]) } else if i := bytes.IndexByte(raw, '{'); i > 0 { raw = raw[i:] } var res OracleResult if err := json.Unmarshal(raw, &res); err != nil { return nil, fmt.Errorf("解析探针输出失败:%v;原始输出=%s", err, truncate(string(raw), 800)) } if !res.OK { return &res, fmt.Errorf("探针报错 %s: %s", res.Error, res.ErrorDetail) } return &res, nil } func boolEnv(b bool) string { if b { return "1" } return "0" } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "…" }