package main import ( "bufio" "context" "errors" "fmt" "io" "log" "os" "os/exec" "strings" "sync" "time" ) // scriptTimeout 脚本执行超时上限(6 小时) const scriptTimeout = 6 * time.Hour // buildTask 单次构建任务的快照,handler 入队时构造。 type buildTask struct { scriptPath string repoName string ref string commitID string } // debounceEntry 单 key 的冷却去重状态。 type debounceEntry struct { mu sync.Mutex lastExec time.Time } var ( // buildCh 单槽队列;handler enqueue 始终非阻塞,worker 唯一消费者 buildCh = make(chan buildTask, 1) // lastExecMap 冷却去重状态,key: "repoName:ref", value: *debounceEntry lastExecMap sync.Map // 防抖冷却时间限制:3 分钟 debounceDuration = 3 * time.Minute ) // enqueueBuildTask 入队;队列已满时丢老取新(drop-oldest) func enqueueBuildTask(t buildTask) { select { case buildCh <- t: // 入队成功后再登记到 dashboard,保证 channel 与列表一致 if dashboard != nil { dashboard.AddPending(t) } return default: // 队列已满,丢弃旧 task select { case <-buildCh: if dashboard != nil { // 同步移除最旧一条,避免 pending 列表与 channel 错位 dashboard.RemovePendingAndPeek(t) } default: } buildCh <- t // 阻塞不会发生,因为我们是唯一发送方 if dashboard != nil { dashboard.AddPending(t) } } } // startBuildWorker 启动单 worker goroutine 持续消费 buildCh func startBuildWorker() { go consumeBuild() } // consumeBuild worker 主循环;取出 task,按冷却规则执行或跳过 func consumeBuild() { for task := range buildCh { if dashboard != nil { dashboard.RemovePendingAndPeek(task) } lockKey := fmt.Sprintf("%s:%s", task.repoName, task.ref) entryVal, _ := lastExecMap.LoadOrStore(lockKey, &debounceEntry{}) entry := entryVal.(*debounceEntry) entry.mu.Lock() now := time.Now() if !entry.lastExec.IsZero() && now.Sub(entry.lastExec) < debounceDuration { remainingSeconds := int((debounceDuration - now.Sub(entry.lastExec)).Seconds()) if remainingSeconds < 0 { remainingSeconds = 0 } entry.mu.Unlock() log.Printf("[SKIP] 3 分钟冷却中,跳过构建 [%s] commit %s 剩余 %d 秒\n", lockKey, shortCommit(task.commitID), remainingSeconds) // 冷却跳过也写入历史,方便追踪 if dashboard != nil { dashboard.StartExecution(task) dashboard.FinishExecution(task, statusSkipped, fmt.Sprintf("冷却中,剩余 %d 秒", remainingSeconds), time.Duration(0)) } continue } entry.lastExec = now entry.mu.Unlock() log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s, commit: %s)\n", task.scriptPath, task.repoName, task.ref, shortCommit(task.commitID)) go runScript(task.scriptPath, task.repoName, task.ref, task.commitID) } } // shortCommit 安全地取 commit 短 ID,避免空值/短值切片越界 panic func shortCommit(commitID string) string { if len(commitID) > 7 { return commitID[:7] } if commitID == "" { return "unknown" } return commitID } // streamLog 逐行读取并实时打印脚本输出,直到 r 关闭 // onLine 在每行非空时回调一次(携带换行符) func streamLog(r io.Reader, tag string, onLine func(line string)) { br := bufio.NewReader(r) for { line, err := br.ReadString('\n') if line != "" { log.Printf("[SCRIPT %s] %s", tag, strings.TrimRight(line, "\r\n")) if onLine != nil { onLine(line) } } if err != nil { return } } } // notifyResult 发送执行结果通知 func notifyResult(ok bool, repoName, ref, commitID string, cost time.Duration, reason string) { state := "成功" if !ok { state = "失败" } title := fmt.Sprintf("%s更新%s", repoName, state) body := fmt.Sprintf("分支: %s\nCommit: %s\n耗时: %s\n原因: %s\n服务器已接收到 Push,更新%s", ref, shortCommit(commitID), cost.Round(time.Second), reason, state) if err := SendNotification(pushURL, globalConfig.DeviceKeys, title, body); err != nil { log.Printf("[WARNING] 推送通知失败: %v", err) return } log.Println("[INFO] 推送通知发送成功") } // runScript 异步执行 Shell 脚本,输出实时流式打印,超时上限 scriptTimeout func runScript(scriptPath, repoName, ref, commitID string) { task := buildTask{ scriptPath: scriptPath, repoName: repoName, ref: ref, commitID: commitID, } start := time.Now() tag := fmt.Sprintf("%s:%s", repoName, ref) log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s) 超时上限 %s", scriptPath, repoName, ref, scriptTimeout) if dashboard != nil { dashboard.StartExecution(task) } ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout) defer cancel() cmd := exec.CommandContext(ctx, "bash", scriptPath) // 超时/取消后最多再给 10 秒让进程优雅退出;超时则强制 SIGKILL cmd.WaitDelay = 10 * time.Second // 传入环境变量,脚本内可读取 cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_REPO_NAME=%s", repoName), fmt.Sprintf("GIT_REF=%s", ref), fmt.Sprintf("GIT_COMMIT_ID=%s", commitID), ) // stdout / stderr 合并到同一个管道,保持原有交错顺序 pr, pw := io.Pipe() cmd.Stdout = pw cmd.Stderr = pw if err := cmd.Start(); err != nil { pw.Close() pr.Close() log.Printf("[ERROR] 脚本启动失败 [%s]: %v", scriptPath, err) notifyResult(false, repoName, ref, commitID, time.Since(start), fmt.Sprintf("启动失败: %v", err)) if dashboard != nil { dashboard.FinishExecution(task, statusFailed, fmt.Sprintf("启动失败: %v", err), time.Since(start)) } return } var wg sync.WaitGroup wg.Go(func() { streamLog(pr, tag, func(line string) { if dashboard != nil { dashboard.AppendLog(line) } }) }) err := cmd.Wait() // 进程退出 + 内部 copy goroutine 结束 pw.Close() // 关闭写端,读端收到 EOF wg.Wait() // 等日志全部刷完再往下走 cost := time.Since(start) // 先判断超时(ctx.Err() 在超时/取消时非 nil) switch { case errors.Is(ctx.Err(), context.DeadlineExceeded): log.Printf("[TIMEOUT] 脚本执行超时 [%s] 已运行 %s 上限 %s", scriptPath, cost.Round(time.Second), scriptTimeout) notifyResult(false, repoName, ref, commitID, cost, "执行超时,已强制终止") if dashboard != nil { dashboard.FinishExecution(task, statusTimeout, "执行超时,已强制终止", cost) } return case err != nil: log.Printf("[ERROR] 脚本执行失败 [%s] 耗时 %s: %v", scriptPath, cost.Round(time.Millisecond), err) notifyResult(false, repoName, ref, commitID, cost, err.Error()) if dashboard != nil { dashboard.FinishExecution(task, statusFailed, err.Error(), cost) } return } log.Printf("[SUCCESS] 脚本执行成功 [%s] 耗时 %s", scriptPath, cost.Round(time.Millisecond)) notifyResult(true, repoName, ref, commitID, cost, "执行成功") if dashboard != nil { dashboard.FinishExecution(task, statusSuccess, "执行成功", cost) } }