
Optimism 仓库 Flake 防治实战op-acceptance-tests 与 op-devstack 的 17 类 CI 不稳定反模式、静态拦截与评审清单【免费下载链接】optimismOptimism is Ethereum, scaled.项目地址: https://gitcode.com/GitHub_Trending/op/optimism本文是 Optimism 单仓monorepo中针对op-acceptance-tests/与op-devstack/的验收测试稳定性指南。它系统梳理了在 CI 中反复出现的 17 类测试不稳定flake反模式给出每类的 BAD/GOOD 代码对照、真实修复案例编号、可被静态检查Semgrep捕获的规则以及静态检查无法覆盖时依赖人工评审的核对清单。读完本文你既能识别 diff 中看起来像正常 Go 代码的 flake 隐患也能掌握 flake 报告的处理流程与仓库的静态强制机制。本文是 docs/ai/writing-acceptance-tests.md 的姊妹篇后者讲解如何写一个干净的测试本文则专门命名那些导致 CI 不稳定的反复出现的反模式、能捕获它们的静态检查以及 linter 抓不到、需要评审人逐一确认的审查问题。如果你正在评审涉及op-acceptance-tests/或op-devstack/的 PR请务必对照文末的 Reviewer checklist 逐项过一遍。为什么 flake 会不断出现教育培训例如别用短超时、别用 sleep并没有从根本上解决问题因为真正导致 flake 的模式在 diff 中非常隐蔽——它们看起来就是合理的 Go 代码。下表汇总了最常见的表面形态与它们真正的失效机理表面形态会因什么而 flake在require.Eventually(...)回调里写require.NoError(x.RPC(...))一次瞬态 RPC 错误变成回调内部的致命FailNow第一次 hiccup 就杀死测试重试循环来不及吸收错误WaitForStall(unsafe)之后接WaitForStall(safe)两次等待各自独立成功但下游代码假设的safe unsafe这一不变量并未被强制head : node.Head(); WaitForOther(head)TOCTOU——第二次调用用到head时它已经过期Sleep(2s); SequenceBlock()看起来像给系统一点时间实际上是缺少对前置条件的等待NewMultiNodeWithoutCheck(); WaitForBudget(60)绕过了 preset 自动检测的同步预算preset 本会为 ELSync 使用正确的 240s 预算测试却硬编码成 120sdefer cancel(); ... defer wg.Wait()LIFO 顺序Wait先执行并永久阻塞因为cancel还没被触发go func(){ require.NoError(...) }()非测试 goroutine 里的FailNow只会退出该 goroutine测试挂起直到包级超时一旦你写过其中某一种很可能也在另一个测试里写过同样的形态。Lint 只能捕获有语法特征的形态其余部分依赖评审人的责任心。反模式目录F1–F17以下每条都源自近一个月内真实发生的修复。PR 编号是修复形态的权威示例可在仓库 git 历史中按编号检索对照。F1 — 轮询/重试回调内的致命断言// BAD —— 第一次瞬态错误就使测试失败sequencer 的 currentJob 残留为 // 非 nil后续每次重试都会命中 ErrConflictingJob测试最终挂死。 require.Eventually(t, func() bool { head : ts.New(parent) require.NoError(t, ts.Next(head)) // -- 第一次卡顿时测试即被杀 return condition(head) }, 30*time.Second, 200*time.Millisecond) // GOOD —— 让循环吸收瞬态错误。 require.Eventually(t, func() bool { head : ts.New(parent) if err : ts.Next(head); err ! nil { log.Warn(transient ts.Next error, err, err) return false } return condition(head) }, 30*time.Second, 200*time.Millisecond)同样的形态适用于assert.NoError、t.Fatal、Require()链——凡是运行时预期会被反复调用的闭包内部都不能出现调用FailNow的断言。示例PR #20651、#20255、#20209、#19976。Lint规则flake-require-in-eventually精确捕获此形态。该规则的 Semgrep 模式位于 .semgrep/rules/go-acceptance-test-flakes.yaml对require.Eventually/require.EventuallyWithT/assert.Eventually/retry.Do回调内的require.*/assert.*NoError、Equal、True、Nil等一律报 ERROR。F2 — goroutine 内的require.*// BAD —— FailNow 只退出该 goroutine主测试挂起直到包级超时。 go func() { for evt : range stream { require.NoError(t, evt.Err) } }() // GOOD —— 通过 t.Errorf非致命或 channel 把错误上抛给测试。 errCh : make(chan error, 1) go func() { for evt : range stream { if evt.Err ! nil { errCh - evt.Err return } } }()示例PR #19976、#20255。Lintflake-require-in-goroutine。规则同时拦截go func(){...}()内的require.*、t.Fatal(...)与t.FailNow(...)理由是FailNow只退出当前 goroutine主测试会挂到包超时而非带着底层错误快速失败。F3 — 相互依赖的两次 RPC 之间的 TOCTOU// BAD —— 两次调用之间 head 可能已前进。 head, err : node.UnsafeHead(ctx) require.NoError(t, err) err node.StartSequencer(ctx, head.Hash) require.NoError(t, err) // block hash does not match // GOOD —— 针对文档化的 stale head 错误重试读用组合。 retry.Do(ctx, func() error { head, err : node.UnsafeHead(ctx) if err ! nil { return err } return node.StartSequencer(ctx, head.Hash) })示例PR #20204。Lint部分覆盖flake-toctou-start-sequencerWARNING 级别。规则用元变量模式匹配$H, $E : $N.UnsafeHead(...)/$N.HeadBlockRef(...)之后紧接着把$H.Hash传给$N.StartSequencer(...)的写法提示应包装为遇到 block hash does not match 时重读的重试循环。F4 — 等待了错误的或完全没有后置条件// BAD —— ReorgTriggered 只保证 reorg 目标区块被重写并不保证 // sequencer 已经越过 verifier 的冻结高度。断言与 reorg 后的 // 出块产生竞态。 chain.ReorgTriggered() require.Greater(t, seq.Number(), ver.Number()) // GOOD —— 显式等待实际被断言的真正条件。 frozen : ver.Head().Number chain.ReorgTriggered() seq.WaitForBlockNumber(frozen 1) require.Greater(t, seq.Number(), ver.Number())// BAD —— proveWithdrawal 在 block.timestamp game.createdAt 之前会一直 // revert重试循环把 30s 预算全部烧在必然 revert 的 estimateGas 上。 retryProve(ctx, 30*time.Second) // GOOD —— 先确定性地等待静态前置条件。 require.Eventuallyf(t, func() bool { head, _ : l1.HeadBlock(ctx) return head.Time gameCreatedAt }, 60*time.Second, 1*time.Second, L1 head past game createdAt) retryProve(ctx, 30*time.Second) // 现在只用于吸收瞬态提交/确认错误// BAD —— CL/supervisor 状态已达目标但 EL 的 label/内容经由异步的 // forkchoice/reorg 路径更新。同步的 EL 读取仍可能观察到旧的 safe label // 或旧区块内容。 cl.Reached(safety.CrossSafe, target, 30) safe : el.BlockRefByLabel(eth.Safe) require.GreaterOrEqual(t, safe.Number, target) // GOOD —— 同时等待断言实际读取的组件与可观察状态。若断言针对 EL label // 或区块内容在同步断言之前必须包含 EL 侧的等待。 dsl.CheckAll(t, cl.ReachedFn(safety.CrossSafe, target, 30), el.ReachedFn(eth.Safe, target, 30), ) safe el.BlockRefByLabel(eth.Safe) require.GreaterOrEqual(t, safe.Number, target)同样的形态也出现在先等 supervisor 或 CL 校验等待随后立刻做 EL 区块内容断言。AwaitValidatedTimestamp与Reached(CrossSafe)只能证明控制面control-plane视图前进了并不自动证明 EL 已经暴露替换后的规范区块或历史证明状态。这也是为什么 DSL 中L2CLNode.Reachedop-devstack/dsl/l2_cl.go与L2ELNode.Reachedop-devstack/dsl/l2_el.go是两个独立的等待原语——断言读哪一侧的状态就要在哪一侧等待。示例PR #20199、#20677、#20482、#20852、#20782。Lint无语义问题需要评审人判断。F5 — 完成信号并不代表工作已完成// BAD —— AwaitBackfillCompleted 在 runLogBackfill 返回 OR 被跳过时即返回 // 例如 L1 head 短暂落后时出现 nil-range。测试断言区块已被封存 // 却得到 latest.Number first.Number 0。 chain.RestartInterop(wipeLogsDBstrue) chain.AwaitBackfillCompleted() require.Greater(t, chain.LatestBlock(), chain.FirstBlock()) // GOOD —— 等待实际可见的后置条件已封存的区块 // 或加强 AwaitBackfillCompleted 要求非空 backfill。 chain.RestartInterop(wipeLogsDBstrue) chain.WaitForBackfillToSeal(types.MinBlocks(2))示例Issue #20690。Lint无。同样的陷阱适用于Stop、Reset、Restart等辅助方法调用返回并不代表所有后台生产者都已静默也不代表所有隐藏缓存已被清空。如果后续断言依赖空的会话状态、排空的队列或不再有新的 payload 到达辅助方法必须保证这个更强的后置条件否则测试就必须直接等待/断言该条件。相关PR #20783。F6 — 绕过 preset 自动预算的手工同步检查// BAD —— preset 的 NewSingleChainMultiNode 会自动检测 ELSync 并应用 4x // 预算。WithoutCheck 加上手工 120 次尝试的循环撤销了该修复。 sys : presets.NewSingleChainMultiNodeWithoutCheck(t, opts) require.NoError(t, dsl.MatchedFn(sys, safety.CrossSafe, 60, 2*time.Second)(ctx)) // GOOD —— 使用自动预算的入口。 sys : presets.NewSingleChainMultiNode(t, opts)在源码层面NewSingleChainMultiNodeWithoutCheck确实是与自动预算入口并存的免检查变体见 op-devstack/presets/singlechain_multinode.go 的NewSingleChainMultiNodeWithoutCheck与NewSingleChainMultiNodeWithoutP2PWithoutCheck注释。它的存在是为了那些确实需要自己控制检查时机的场景但如果只是嫌自动预算太慢而绕过它就会丢失 preset 为 ELSync 自动检测并扩展的预算逻辑。示例PR #20454、#20343。Lintflake-without-check-with-manual-budget。规则匹配$SYS : presets.$NEW(...)其中$NEW匹配.*WithoutCheck.*之后又出现dsl.MatchedFn(...)/dsl.ReachedFn(...)的组合提示应改用自动预算的New*MultiNode入口。F7 — 一次性快照同步检查在 reorg 时持有过期目标// BAD —— target 在循环外只采样一次。若参考节点 reorg 掉该区块 // 谓词会永远等待一个已不存在的 hash。 target : ref.Head() require.Eventually(t, func() bool { return base.Head().Number target.Number }, 60*time.Second, 1*time.Second) // GOOD —— 每次尝试都重新采样两侧允许小的有界差距 // 并在较低高度上验证 hash 一致。 require.Eventually(t, func() bool { a, b : ref.Head(), base.Head() if absDiff(a.Number, b.Number) 5 { return false } return base.HashAt(min(a.Number, b.Number)) ref.HashAt(min(a.Number, b.Number)) }, 60*time.Second, 1*time.Second)示例PR #20405。Lint部分覆盖。F8 — 异步驱动操作之间的time.Sleep// BAD —— 出块是异步的sleep 2s 通常够用直到 CI 负载变高。 ts.SequenceBlock(parent) time.Sleep(2 * time.Second) ts.SequenceBlock(child) // GOOD —— 等待区块在消费端可见。 ts.SequenceBlock(parent) chain.WaitForBlockNumber(parent.Number 1) ts.SequenceBlock(child)writing-acceptance-tests.md早已明令禁止time.Sleep现在 Lint 已将其强制化。DSL 侧提供了对应的等待原语例如 op-devstack/dsl/el.go 中的WaitForBlockNumber(targetBlock)。示例Issue #20198。Lintflake-sleep-in-test同时匹配裸time.Sleep(...)与clock.SystemClock.SleepCtx(...)。规则注释还记录了一个被放弃的尝试-time.After(d)型睡眠规则曾被原型化但被删除因为它在select { case -time.After(d): ... }合法超时上 100% 误报——这也说明静态检查需要精确到形状误报率高的规则宁可不上。F9 — 两个并行等待之间的不变量未被强制// BAD —— sequencer 与 batcher 并行停止。WaitForStall 在各自独立静默后 // 返回但 safe unsafe 可能持续存在。下游依赖 safe unsafe 的代码 // 时间戳算术会计算出无意义的结果。 stopAllSequencers() stopAllBatchers() waitForStall(LocalUnsafe) waitForStall(LocalSafe) // ... 这里 safe 仍可能 unsafe // GOOD —— 按顺序停止并在退出前收敛不变量。 stopAllSequencers() waitForStall(LocalUnsafe) unsafeNumber : head(LocalUnsafe).Number cl.Reached(LocalSafe, unsafeNumber, 30*time.Second) stopAllBatchers() require.Equal(t, head(LocalSafe).Number, head(LocalUnsafe).Number)DSL 中的L2CLNode.WaitForStall(lvl safety.Level)op-devstack/dsl/l2_cl.go正是等待某级安全标签停止前进的原语——两次独立调用各自成功并不等于它们最终收敛到同一高度。示例PR #20580。Lint无。F10 — 导致清理死锁的 defer 顺序// BAD —— 源码顺序cancel 先 deferWait 后 defer。 // defer 是 LIFO后注册的 Wait 先弹出阻塞在等待一个本身正在 // 等待 context 的 goroutine 上——而 cancel 此时还没触发。死锁直到包超时。 ctx, cancel : context.WithCancel(t.Context()) defer cancel() // 先注册 → 最后执行 wg.Add(1) go collector(ctx, wg) defer wg.Wait() // 后注册 → 最先执行 → 阻塞 → 死锁 // GOOD —— 单个 defer显式顺序。 ctx, cancel : context.WithCancel(t.Context()) wg.Add(1) go collector(ctx, wg) defer func() { cancel(); wg.Wait() }()示例PR #20600。Lintflake-defer-cancel-before-wait。为避免对每一对defer wg.Wait(); ... defer wg.Done()误报规则的$CANCEL元变量被正则限制为常见取消函数名cancel、cancelCtx、cancelFn、stop、stopFn、cleanup、close、shutdown等前缀。F11 — 后台测试夹具与测试竞争共享资源诚实 proposer 与测试会在同一 factory 时间戳上同时尝试创建 dispute game。Game UUID 在(gameType, rootClaim, extraData)上是确定性的因此并发创建会冲突并报GameAlreadyExists。// BAD —— preset 启动了真实的 proposer与测试自身的 game 创建并发运行。 sys : presets.NewSuperFaultProofs(t) sys.DGF.CreateGame(...) // 与 proposer 的自主建 game 竞争 // GOOD —— 退出测试不需要的后台角色。 sys : presets.NewSuperFaultProofs(t, presets.WithoutHonestProposer()) sys.DGF.CreateGame(...)WithoutHonestProposer是 preset 的正式选项之一其语义是跳过启动诚实 proposerop-proposerZK preset 下为 kona-sp1-proposer见 op-devstack/presets/options.go。总体原则一个 preset 应该是一个最小宇宙。如果测试不演练某个组件preset 就不应该运行它。加一个 opt-out 选项而不是试图与竞争的后台角色共存。示例PR #20575、Issue #20574。Lint无。F12 — 把推测性事件当作最终事件// BAD —— 第一个包含该 tx 的 flashblock 可能被后续块取代tx 落在下一个块。 fb : stream.WaitFor(func(fb Flashblock) bool { return fb.Contains(txHash) }) require.Equal(t, expectedBlock, fb.BlockNumber) // GOOD —— 收集候选按确认的包含块过滤。 flashes : stream.Collect(20*time.Second) receipt : tx.WaitForReceipt() matching : filterByBlock(flashes, receipt.BlockNumber) require.NotEmpty(t, matching)示例PR #20066、#19976。Lint无。F13 — 共享 nonce 源的并发交易提交// BAD —— alice 并发提交两笔交易且不做 nonce 协调第二笔拿到过期的 // nonce 0而第一笔已经用掉了 nonce 0。 go alice.Transfer(bob, amount) go alice.Transfer(carol, amount) // GOOD —— 串行化或改用各自独立的已注资 EOA。 g, _ : errgroup.WithContext(t.Context()) g.Go(func() error { return sys.NewFundedEOA().Transfer(bob, amount) }) g.Go(func() error { return sys.NewFundedEOA().Transfer(carol, amount) }) require.NoError(t, g.Wait())示例Issue #20345。Lint无语义问题。F14 — 用MarkFlaky当创可贴而不是修复MarkFlaky是升级手段escalation不是解决方案。只有当以下条件全部满足时才允许把测试标记为 flaky存在一个带可复现失败日志的C-flakeGitHub issue标记处通过注释引用该 issue 编号拥有该测试的团队已确认 ownership。没有 issue 背书的MarkFlaky是隐形债务。Lint 会标记所有不引用 issue 的MarkFlaky调用。在实现层面MarkFlaky(reason string)是devtest.T接口的方法op-devstack/devtest/testing.go其语义是把将来的失败降级为 skip除非设置了DEVNET_FAIL_FLAKY_TESTS——配套的单元测试op-devstack/devtest/testing_test.go验证了 skip 传播到子测试、强制失败开关、注解行为等细节说明这是一个被刻意设计为有审批门槛的逃生舱。示例PR #20200。Lintflake-markflaky-without-issueWARNING 级别要求调用处或其紧邻注释包含#NNNN形式的 issue 引用规则豁免了devtest包其中包含对MarkFlaky特性本身的单元测试以及sysgo的运行时包装器由调用方提供原因包括#NNNN引用。F15 — 在启动服务前选择空闲端口先找一个空闲端口、再把端口号传给另一个进程本质上是 time-of-check/time-of-useTOCTOU竞态关闭探测用的 listener 会释放端口另一进程可能在服务启动前就绑定它。重试只能降低失败概率并不能让分配变得安全。// BAD —— listener.Close() 之后端口不再被保留。 listener, err : net.Listen(tcp, 127.0.0.1:0) require.NoError(t, err) port : listener.Addr().(*net.TCPAddr).Port require.NoError(t, listener.Close()) startProcess(--port strconv.Itoa(port)) // GOOD —— 由服务在实际 bind 时请 OS 分配端口。 startProcess(--port0) addr : waitForBoundAddressFromStartupLog()更优做法是让最终持有 socket 的服务绑定端口0然后从活着的 listener 或结构化的启动输出中发现绑定地址。如果服务无法上报其绑定地址就为该服务增加该能力或把已打开的 listener 的所有权转移给它不要预选端口再释放。示例PR #21872。Lint无跨进程生命周期问题。F16 — 代理仍指向已停止进程的地址长期存活的代理在拥有者进程停止后仍保留其上游地址会把流量转发到已释放的端口——OS 可能把该端口分配给无关进程从而静默地把一个节点的端点错接到另一个节点。必须在发起停止之前清除上游停止后再清除只会缩小窗口而不会关闭它而提前 return 的错误路径会完全跳过清除。// BAD —— Stop 释放端口在 clear 执行前代理一直转发到过期地址 // 且 Stop 出错时永远不会执行 clear。 err : n.sub.Stop(true) require.NoError(t, err) n.proxy.ClearUpstream() // GOOD —— 先清除新的拨号被拒绝而非被错接。 n.proxy.ClearUpstream() err : n.sub.Stop(true) require.NoError(t, err)不变量一个代理的上游地址只应在拥有该地址的进程存活且监听时被设置。示例PR #22014。Lint无跨进程生命周期问题。F17 — 设置阶段触发两个节点同时拨号让对等连接的两端在设置阶段互相拨号是一种竞态reth 会立即拨可信对等节点当两个拨号在飞行中交叉时每个节点都会以AlreadyConnected拒绝对方的连接——双方都挂掉reth 没有同时连接仲裁机制而可信对等重拨的退避约 30s可能超过校验预算。// BAD —— admin_addPeer 发起发起方的拨号接受方的 trusted add 让 reth // 立即回拨。两个在途拨号可能互相杀死。 addPeer(initiator, acceptorEnode) addTrustedPeer(initiator, acceptorEnode) addTrustedPeer(acceptor, initiatorEnode) waitForPeerConnected(initiator, acceptorID) // GOOD —— 一次只拨一个先确认会话已建立再对接受方做 trusted add // 后者只是把活会话的对等类型升级。 addPeer(initiator, acceptorEnode) addTrustedPeer(initiator, acceptorEnode) waitForPeerConnected(initiator, acceptorID) addTrustedPeer(acceptor, initiatorEnode)示例Issue #22486、PR #22487。Lint无跨进程生命周期问题。Reviewer checklist评审涉及op-acceptance-tests/或op-devstack/的 PR 时逐项确认F1Eventually/Until/retry.Do回调内部是否存在require.*/assert.*调用应改为if err ! nil { return false }。F2go func(){...}()内部是否存在require.*goroutine 断言在失败时会挂死测试。F3是否存在读 X然后在下一个 RPC 中使用 X的模式X 可能已过期F4测试中的每个断言之前是否等待了其精确的后置条件而非sequencer 已启动、CL 达到 CrossSafe、supervisor 校验了时间戳这类代理条件当断言读取的是 EL 状态时F5Await*/*Completed/*Done/Stop/Reset方法——它们保证的是工作已完成、隐藏状态已排空还是仅仅信号/API 调用返回了F6手工同步检查——是否存在WithoutCheck后接手工预算应使用自动预算的 preset。F7同步检查的参考值是在重试循环内部采样的还是在循环外只捕获一次F8测试代码中是否存在time.Sleep应替换为WaitFor*。F9两次独立等待——退出时是否断言了它们之间的不变量F10多个 defer——LIFO 顺序是否造成死锁应合并为单个闭包。F11preset 是否运行了测试不演练的组件考虑增加 opt-out。F12是否存在对可被取代的事件流做首个匹配事件判断的逻辑F13是否存在共享 nonce 源的并发交易提交F14新增的MarkFlaky是否关联了 C-flake issue 与 ownerF15是否存在选择空闲端口 → 关闭 listener → 在该端口上启动服务的代码F16被长期存活代理前置的进程其停止路径是否在发起停止之前清除了代理上游F17是否存在让两个节点同时互相拨号的设置路径例如一方 add-peer、另一方有 trusted/static 条目确认一条连接建立之后才触发任何会回拨的动作。收到 flake 报告时的处理流程创建C-flakeissue附上 CircleCI 链接、失败的断言以及周边的日志上下文。如可用附上 CircleCI Insights 的 flake-history。在本文档中搜索该失败形态——大多数 flake 是复发。若找到给 issue 打上对应的 F-number 标签。如果是新形态修复落地后在本文档中新增一条 F-entry附上 PR 编号。该目录就是仓库的机构记忆institutional memory。如果是复发思考是否应收紧现有 lint 规则或目录条目是否需要更锐利的示例。目录存在的意义就是让下一次出现可被捕获。静态强制Semgrep 规则所有可捕获形态的 lint 规则都定义在 .semgrep/rules/go-acceptance-test-flakes.yaml 中并在 CI 的semgrep-scan-localjob 下运行规则失败会阻塞合并。这些规则被刻意限定在op-acceptance-tests/与op-devstack/路径内——它们编码的是测试专用约定而非通用 Go 风格。当前规则集与本文 F 编号的对应关系如下flake-require-in-eventually→ F1ERRORflake-require-in-goroutine→ F2ERROR同时覆盖t.Fatal/t.FailNowflake-toctou-start-sequencer→ F3WARNING部分覆盖flake-without-check-with-manual-budget→ F6ERRORflake-sleep-in-test→ F8ERROR含clock.SystemClock.SleepCtxflake-defer-cancel-before-wait→ F10ERRORflake-markflaky-without-issue→ F14WARNINGflake-short-test-timeout→ 附加规则INFO测试代码中context.WithTimeout使用低于 30s 的字符串字面量超时在 CI 负载下容易变脆提示改用 preset 默认超时或更宽松的预算本地运行方式仓库 README 级说明semgrep scan --config .semgrep/rules/go-acceptance-test-flakes.yaml op-acceptance-tests/ op-devstack/。对于确属有意保留的真阳性极少数且需在评审中讨论过可使用// nosemgrep: rule-id注释跳过——例如flake-sleep-in-test在确实没有链上事件可等待的罕见场景下要求带解释性注释使用// nosemgrep。结语从 F1 的回调内致命断言到 F17 的双向同时拨号这 17 类反模式覆盖了验收测试中从断言语义、并发模型、等待条件、defer 生命周期到跨进程资源管理的全部 flake 来源。仓库的策略是三管齐下语义清晰的反模式目录本文负责沉淀机构记忆精确限域的 Semgrep 规则.semgrep/rules/go-acceptance-test-flakes.yaml负责拦截有语法特征的形态评审清单负责覆盖 linter 无法企及的语义与跨进程生命周期问题。写测试时牢记writing-acceptance-tests.md的箴言——可靠性来自等待正确的条件而不是等待更久评审时把本文的 checklist 当作硬性门槛遇到新 flake 时让它成为 F18 的素材而不是仓库的又一次 rerun。【免费下载链接】optimismOptimism is Ethereum, scaled.项目地址: https://gitcode.com/GitHub_Trending/op/optimism创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考