Skip to content

fix(reload): tiered reload to eliminate BPF map races during staged handoff - #1016

Open
olicesx wants to merge 4 commits into
daeuniverse:mainfrom
olicesx:fix/tiered-reload-bpf-races
Open

olicesx wants to merge 4 commits into
daeuniverse:mainfrom
olicesx:fix/tiered-reload-bpf-races

Conversation

@olicesx

@olicesx olicesx commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes silent traffic blocking (Operation not permitted) after staged same-port hot handoff reload, reported in #1013.

Root Cause

When a user modifies DNS runtime parameters (e.g., optimistic_cache, optimistic_cache_ttl, max_cache_size) and triggers a reload, the staged handoff path shares the BPF object between old and new control planes. The DNS fingerprint included these runtime-tunable fields, causing domain_routing_map to be cleared synchronously and replayed asynchronously (via a capacity-1024 channel with silent drops) while the old control plane was still actively routing packets on the shared map. This created a race window where the old plane lost domain routing entries, causing transient routing failures.

Two-Layer Fix

Layer 0 — DNS fingerprint fix:

  • Exclude runtime-tunable parameters (OptimisticCache, OptimisticCacheTtl, MaxCacheSize) from dnsConfigFingerprint; they are atomic-tunable via DnsController.UpdateRuntime and do not affect BPF map state.
  • Add dnsRoutingUnchanged flag to ControlPlane (passed via constructor); when true, CommitPreparedDatapath and the non-deferred commit path skip clearReloadDomainRoutingMap + replayDnsReloadCache.

Layer 1 — Tiered reload via bpfDatapathChanged():

  • New function detects all B-class map changes: routing rules, routing fallback, group definitions, DNS routing, interface bindings, conn-state map size, and SoMarkFromDae.
  • When BPF datapath config changes, force a full reload (fresh BPF objects) instead of staged handoff, eliminating the shared-map race window entirely.
  • Safe (pure userspace) fields — log_level, check URLs, dial_mode, sniffing_timeout, TLS settings, bandwidth limits, optimistic_cache, etc. — continue to use staged handoff for zero-downtime reload.

Reload Decision Matrix

Config change type Reload method BPF maps
Runtime params (optimistic_cache, etc.) Staged handoff Shared, untouched
Safe params (log_level, check URLs, dial_mode, etc.) Staged handoff Shared, untouched
Routing / group / DNS routing changes Full reload (new) Fresh objects, no race
Port change Full reload Fresh objects

Test Coverage

  • 11 new TestBpfDatapathChanged_* test cases covering all detected fields + safe-field exclusions
  • Updated TestDNSConfigFingerprintCoversAllDnsFields with excluded-set contract
  • gofmt, go vet clean; full cmd + control test suites pass with zero regressions

Test Plan

  • go build -tags dae_stub_ebpf ./cmd/ passes
  • go vet -tags dae_stub_ebpf ./cmd/ ./config/ ./control/ passes
  • gofmt -l clean on all modified files
  • go test -tags dae_stub_ebpf ./cmd/ ./control/ — all pass
  • CI: go-lint, ebpf-lint, Go Unit Test, BPF Audit all green
  • Deployed to production (dae.lan, kernel 6.17, AVX2 build) — running stable with active traffic

Fixes #1013

olicesx added 2 commits June 16, 2026 13:13
…andoff (dae#1013)

Two-layer fix for silent traffic blocking after staged same-port handoff:

Layer 0 — DNS fingerprint fix:
- Exclude runtime-tunable params (OptimisticCache, OptimisticCacheTtl,
  MaxCacheSize) from dnsConfigFingerprint; they are atomic-tunable via
  DnsController.UpdateRuntime and do not affect BPF map state.
- Add dnsRoutingUnchanged flag to ControlPlane; when true, skip
  clearReloadDomainRoutingMap + replayDnsReloadCache in both
  CommitPreparedDatapath and the non-deferred commit path.

Layer 1 — Tiered reload via bpfDatapathChanged():
- New function detects all B-class map changes (routing rules, groups,
  DNS routing, interface bindings, conn-state map size, SoMarkFromDae).
- When BPF datapath config changes, force a full reload (fresh BPF
  objects) instead of staged handoff, eliminating the shared-map race
  window where the old plane loses domain routing.
- Safe (pure userspace) fields — log_level, check URLs, dial_mode,
  sniffing_timeout, TLS, bandwidth, optimistic_cache, etc. — continue
  to use staged handoff for zero-downtime reload.

Tests: 11 new TestBpfDatapathChanged_* cases + updated fingerprint
coverage test. go vet + gofmt clean, full cmd/control suites pass.
@jschwinger233

Copy link
Copy Markdown
Member

我还以为可以把 reload 删掉,逃

@itoywh

itoywh commented Jun 18, 2026

Copy link
Copy Markdown
Contributor
image 实机测试,还有问题

itoywh pushed a commit to itoywh/dae that referenced this pull request Jun 18, 2026
…, and recovery steps

Records:
- Latest deployed version: v2.0-custom @ 151de49
- GitHub PR status (daeuniverse#1009, daeuniverse#1011, daeuniverse#1016)
- 2.4 server fault timeline (2026-06-18 10:11 ~ 11:00)
- Root cause analysis (config parse error + tail timeout 5s)
- Known PR daeuniverse#1016 limitation (same-config reload still uses staged handoff)
- Configuration pitfalls (Chinese comments cause token error)
- Deployment and recovery commands
@olicesx
olicesx force-pushed the fix/tiered-reload-bpf-races branch 2 times, most recently from b0cc523 to 45f4f7f Compare June 18, 2026 06:44
@itoywh

itoywh commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

45f4f7f 实机验证结果

测试环境: ImmortalWrt 25.12.0, x86_64, Kernel 6.12.87
dae 版本: cherry-pick 45f4f7f 到 v2.0-custom 分支 (commit 3d17895)

测试过程

  1. 安装 dae v1.0 -> 替换 binary -> 启动
  2. 连续执行 5 次 dae reload
  3. 每次 reload 后通过 wget 验证翻墙连通性 (https://www.google.com/generate_204)

测试结果

测试项 结果
第 1 次 reload 翻墙正常 (Google 204)
第 2 次 reload 翻墙正常 (Google 204)
第 3 次 reload 翻墙正常 (Google 204)
第 4 次 reload 翻墙正常 (Google 204)
第 5 次 reload 翻墙正常 (Google 204)
closeTail timeout 5s 未再出现

验证截图

日志确认流量正常经 dae 代理:

[2026-06-18 15:44:46] INFO 192.168.2.4:58894 <-> 142.251.152.119:443 dialer=s4 dscp=0 ip=142.251.152.119:443 mac=bc:24:11:44:90:5c network=tcp4 outbound=proxy pname=wget policy=fixed_fallback sniffed=www.google.com

结论

45f4f7f 修复了 #1013 的 reload 后翻墙静默阻断问题,5 次连续 reload 均验证通过。之前每次 reload 必出现的 closeTail 超时不再触发,翻墙流量保持正常。

When config is unchanged and a reload is triggered, the staged handoff
path shares BPF objects between old and new generations. The old
generation's closeTail() calls core.Close() synchronously within a 5s
timeout window. core.Close() runs TC filter detachment
(netlink.FilterDel) which can exceed the timeout, causing "control
plane close tail timed out" warnings (dae#1013).

In staged handoff, by the time closeTail() runs, EjectBpf() has already
transferred BPF ownership to the new generation. core.Close() only
needs to detach TC filters and release the UDP tracker — neither is
time-critical because the new generation already has its own TC
filters attached.

Run core.Close() in a background goroutine when isBpfEjected() is true.
This check covers all staged handoff scenarios including the first
reload (P0→P1) where sharedBpfReload is false but EjectBpf has been
called. Using bpfEjected rather than sharedBpfReload ensures the
async path is taken based on actual BPF ownership state, not creation
history.

This preserves zero-downtime staged handoff while eliminating the
closeTail timeout.

Reported-by: @itoywh
See: dae#1013
@olicesx
olicesx force-pushed the fix/tiered-reload-bpf-races branch from 45f4f7f to 9f65298 Compare June 18, 2026 08:13
@olicesx

olicesx commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

感谢 @itoywh 的实机验证!

基于反馈重新审查后,我们对 45f4f7f 做了一处重要修正(9f65298):

问题

45f4f7f 中的 bpfDatapathChanged() 会将路由规则、group、DNS 路由等配置变更强制走 full reload(重新加载全部 eBPF 程序),而非 staged handoff。这改变了本 PR 之前的行为——原来只要端口没变,所有 reload 都走 staged handoff(零停机)。

staged handoff 的设计目的就是让策略变更无缝迁移。#1013 的根因是 DNS 运行时参数变更触发 domain_routing_map clear+replay 竞态,已由 Layer 0(dnsRoutingUnchanged + fingerprint 排除运行时参数)修复。bpfDatapathChanged 是多余的安全网,反而回退了零停机能力。

修正

  • 删除 bpfDatapathChanged(),恢复 stagedHotHandoff := !portChanged && listener != nil
  • 保留 Layer 0(DNS fingerprint 修复)和 closeTail 异步化
  • 净删除 228 行

验证

在 dae.lan(Kernel 6.17, AVX2)上测试全部通过:

场景 reload 路径 closeTail timeout 连通性
配置不变纯 reload ×5 staged handoff 未出现
sniffing_timeout 变更 staged handoff 未出现
路由规则变更 staged handoff 未出现
DNS upstream 变更 staged handoff 未出现
optimistic_cache 变更(#1013 原场景) staged handoff 未出现

关键点:路由规则变更现在正确走 staged handoff(零停机),而非 full reload。

@itoywh

itoywh commented Jun 21, 2026

Copy link
Copy Markdown
Contributor
image 真机测试,还是遇到卡死情况

…routing_map race

When DNS routing config changes (upstream, routing rules, etc.), the
non-staged constructor path calls clearReloadDomainRoutingMap +
replayDnsReloadCache on the shared BPF object. Under staged handoff the
old generation keeps routing traffic on the same map during its drain
period, so the synchronous clear creates a race window where the old
plane loses domain routing entries and returns Operation not permitted
(dae#1013).

The previous fix (9f65298) removed bpfDatapathChanged() believing Layer
0 (dnsRoutingUnchanged flag) was sufficient. Layer 0 only covers
runtime-tunable parameters (optimistic_cache etc.) that are excluded
from the fingerprint; when DNS routing itself changes,
dnsRoutingUnchanged is false and clear+replay still runs on the shared
map during staged handoff.

Gate stagedHotHandoff on dnsConfigEqual(conf, newConf) so that DNS
routing changes fall back to non-staged reload, where the old generation
is ejected immediately and clear+replay runs synchronously in the new
plane's constructor with no drain-period race.

Reported by @itoywh: daeuniverse#1016
@olicesx

olicesx commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

感谢 @itoywh 的详细复现和实机验证!这个分析完全正确。

根因确认

9f65298 移除 bpfDatapathChanged() 后,Layer 0(dnsRoutingUnchanged flag)只覆盖了 DNS 运行时参数optimistic_cache 等)变更的场景——这些参数已从 fingerprint 中排除,dnsConfigEqual 返回 truednsRoutingUnchanged=true,跳过 clear+replay,staged handoff 安全。

但当 DNS 路由本身变更(upstream、routing 规则等)时,dnsConfigEqual 返回 falsednsRoutingUnchanged=false,构造函数中的 clearReloadDomainRoutingMap + replayDnsReloadCache 仍然会在共享 BPF map 上执行。此时旧控制面还在 drain 期活跃路由,查到被清空的 domain_routing_mapOperation not permitted

这与 #1013 原始报告是同一个竞态,只是触发条件不同:

场景 dnsConfigEqual dnsRoutingUnchanged clear+replay 竞态
运行时参数变更(optimistic_cache 等) true true 跳过 ✅ 无
DNS 路由变更(upstream/routing) false false 执行

bpfDatapathChanged() 在 DNS 路由变更时强制 full reload,正是堵住了这个窗口。移除它后窗口重新打开。

修复

已推送 9a6efd0:在 staged handoff 条件中增加 dnsConfigEqual(conf, newConf) 检查。

stagedHotHandoff := !portChanged && listener != nil && dnsConfigEqual(conf, newConf)

DNS 路由变更时不再走 staged handoff,而是走非 staged 路径:EjectBpf() 立即弹出旧控制面的 BPF 所有权,新控制面同步创建并执行 clear+replay,旧控制面立即 retire。竞态窗口从「整个 drain 期」缩小到「clear → replay 的同步函数调用」(微秒级),实际不再触发。

修复后行为矩阵

配置变更场景 reload 路径 零停机 竞态
运行时参数(optimistic_cache 等) staged handoff ✅ 无
安全参数(log_level、check URLs 等) staged handoff ✅ 无
DNS 路由变更(upstream/routing) 非 staged(立即 retire 旧代) ✅ 无
端口变更 full reload ✅ 无

DNS 路由变更失去零停机是已知的 trade-off:staged handoff 的 clear+replay 设计在共享 map 上有结构性竞态,full reload 虽有短暂中断但比静默阻断安全。后续可以考虑增量更新 domain_routing_map(只删不用的、不全清)来恢复这个场景的零停机,但那是独立优化。

能否帮忙在 v2.0-custom 上 cherry-pick 9a6efd0 再验证一次?

1 similar comment
@olicesx

olicesx commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

感谢 @itoywh 的详细复现和实机验证!这个分析完全正确。

根因确认

9f65298 移除 bpfDatapathChanged() 后,Layer 0(dnsRoutingUnchanged flag)只覆盖了 DNS 运行时参数optimistic_cache 等)变更的场景——这些参数已从 fingerprint 中排除,dnsConfigEqual 返回 truednsRoutingUnchanged=true,跳过 clear+replay,staged handoff 安全。

但当 DNS 路由本身变更(upstream、routing 规则等)时,dnsConfigEqual 返回 falsednsRoutingUnchanged=false,构造函数中的 clearReloadDomainRoutingMap + replayDnsReloadCache 仍然会在共享 BPF map 上执行。此时旧控制面还在 drain 期活跃路由,查到被清空的 domain_routing_mapOperation not permitted

这与 #1013 原始报告是同一个竞态,只是触发条件不同:

场景 dnsConfigEqual dnsRoutingUnchanged clear+replay 竞态
运行时参数变更(optimistic_cache 等) true true 跳过 ✅ 无
DNS 路由变更(upstream/routing) false false 执行

bpfDatapathChanged() 在 DNS 路由变更时强制 full reload,正是堵住了这个窗口。移除它后窗口重新打开。

修复

已推送 9a6efd0:在 staged handoff 条件中增加 dnsConfigEqual(conf, newConf) 检查。

stagedHotHandoff := !portChanged && listener != nil && dnsConfigEqual(conf, newConf)

DNS 路由变更时不再走 staged handoff,而是走非 staged 路径:EjectBpf() 立即弹出旧控制面的 BPF 所有权,新控制面同步创建并执行 clear+replay,旧控制面立即 retire。竞态窗口从「整个 drain 期」缩小到「clear → replay 的同步函数调用」(微秒级),实际不再触发。

修复后行为矩阵

配置变更场景 reload 路径 零停机 竞态
运行时参数(optimistic_cache 等) staged handoff ✅ 无
安全参数(log_level、check URLs 等) staged handoff ✅ 无
DNS 路由变更(upstream/routing) 非 staged(立即 retire 旧代) ✅ 无
端口变更 full reload ✅ 无

DNS 路由变更失去零停机是已知的 trade-off:staged handoff 的 clear+replay 设计在共享 map 上有结构性竞态,full reload 虽有短暂中断但比静默阻断安全。后续可以考虑增量更新 domain_routing_map(只删不用的、不全清)来恢复这个场景的零停机,但那是独立优化。

能否帮忙在 v2.0-custom 上 cherry-pick 9a6efd0 再验证一次?

@itoywh

itoywh commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

感谢 @itoywh 的详细复现和实机验证!这个分析完全正确。

根因确认

9f65298 移除 bpfDatapathChanged() 后,Layer 0(dnsRoutingUnchanged flag)只覆盖了 DNS 运行时参数optimistic_cache 等)变更的场景——这些参数已从 fingerprint 中排除,dnsConfigEqual 返回 truednsRoutingUnchanged=true,跳过 clear+replay,staged handoff 安全。

但当 DNS 路由本身变更(upstream、routing 规则等)时,dnsConfigEqual 返回 falsednsRoutingUnchanged=false,构造函数中的 clearReloadDomainRoutingMap + replayDnsReloadCache 仍然会在共享 BPF map 上执行。此时旧控制面还在 drain 期活跃路由,查到被清空的 domain_routing_mapOperation not permitted

这与 #1013 原始报告是同一个竞态,只是触发条件不同:

场景 dnsConfigEqual dnsRoutingUnchanged clear+replay 竞态
运行时参数变更(optimistic_cache 等) true true 跳过 ✅ 无
DNS 路由变更(upstream/routing) false false 执行
bpfDatapathChanged() 在 DNS 路由变更时强制 full reload,正是堵住了这个窗口。移除它后窗口重新打开。

修复

已推送 9a6efd0:在 staged handoff 条件中增加 dnsConfigEqual(conf, newConf) 检查。

stagedHotHandoff := !portChanged && listener != nil && dnsConfigEqual(conf, newConf)

DNS 路由变更时不再走 staged handoff,而是走非 staged 路径:EjectBpf() 立即弹出旧控制面的 BPF 所有权,新控制面同步创建并执行 clear+replay,旧控制面立即 retire。竞态窗口从「整个 drain 期」缩小到「clear → replay 的同步函数调用」(微秒级),实际不再触发。

修复后行为矩阵

配置变更场景 reload 路径 零停机 竞态
运行时参数(optimistic_cache 等) staged handoff ✅ ✅ 无
安全参数(log_level、check URLs 等) staged handoff ✅ ✅ 无
DNS 路由变更(upstream/routing) 非 staged(立即 retire 旧代) ❌ ✅ 无
端口变更 full reload ❌ ✅ 无
DNS 路由变更失去零停机是已知的 trade-off:staged handoff 的 clear+replay 设计在共享 map 上有结构性竞态,full reload 虽有短暂中断但比静默阻断安全。后续可以考虑增量更新 domain_routing_map(只删不用的、不全清)来恢复这个场景的零停机,但那是独立优化。

能否帮忙在 v2.0-custom 上 cherry-pick 9a6efd0 再验证一次?

image

@itoywh

itoywh commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

真机测试,还是有问题 ,头大
image

itoywh added a commit to itoywh/dae that referenced this pull request Jul 9, 2026
…aeuniverse#1039)

Add post-commit datapath validation that checks dae0 (handle 0x2022) and all
LAN/WAN interfaces (handle 0x2023) have TC filters attached after
commitInterfaceBindings. Silent bind failures would otherwise cause traffic
to bypass the proxy with zero error feedback (daeuniverse#1013 class of bugs).

Validates both reload paths (non-deferred + staged handoff). On failure,
returns descriptive error listing every missing binding.

Testability: linkByName / filterLister are package-level vars so unit tests
can inject mocks without touching the real netlink stack. Adds
control_plane_core_test.go covering present/missing/link-not-found cases.

Relates: daeuniverse#1013 daeuniverse#1016 daeuniverse#1037 daeuniverse#1038
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

eBPF: 流量在 staged same-port handoff reload 后可能被静默阻断 (v2.0.0rc1)

3 participants