Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions .github/workflows/policy-smart-sci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
name: Policy Smart SCI

on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- "common/**/*.go"
- "component/**/*.go"
- "control/**/*.go"
- "go.mod"
- "go.sum"
- ".github/workflows/policy-smart-sci.yml"
workflow_dispatch:

permissions:
contents: read
pull-requests: write

jobs:
sci-1:
name: SCI-1 Strategy Correctness
runs-on: ubuntu-22.04
env:
GOWORK: off
steps:
- uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: |
go.mod
go.sum

- name: Run SCI-1 tests
shell: bash
run: |
set -euo pipefail
tmp="$(mktemp)"
go test ./component/outbound/dialer ./component/outbound \
-run 'Test.*(Smart|Penalty|EMA|EffectiveLatency|Cap).*' \
-count=1 2>&1 | tee "$tmp"
if grep -q "no tests to run" "$tmp"; then
echo "SCI-1 failed: no tests to run"
exit 1
fi

sci-2:
name: SCI-2 TCP Retry Semantics
runs-on: ubuntu-22.04
env:
GOWORK: off
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: |
go.mod
go.sum

- name: Install Dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y clang llvm make

- name: Prepare BPF generated files
run: |
git submodule update --init --recursive
make ebpf

- name: Run SCI-2 tests
shell: bash
run: |
set -euo pipefail
tmp="$(mktemp)"
go test ./control \
-run 'TestRouteDialTcp.*(Retry|Fallback|AllFailed|Dedup|Timeout).*|TestContainsDialer' \
-count=1 2>&1 | tee "$tmp"
if grep -q "no tests to run" "$tmp"; then
echo "SCI-2 failed: no tests to run"
exit 1
fi

sci-3:
name: SCI-3 Concurrency Race
runs-on: ubuntu-22.04
env:
GOWORK: off
CGO_ENABLED: "1"
steps:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: |
go.mod
go.sum

- name: Install Dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y clang llvm make gcc

- name: Prepare BPF generated files
run: |
git submodule update --init --recursive
make ebpf

- name: Run SCI-3 race tests
shell: bash
run: |
set -euo pipefail
tmp1="$(mktemp)"
tmp2="$(mktemp)"
go test ./component/outbound/dialer -race \
-run 'Test.*(DialerCheck|DialerLogUnavailable|AliveDialerSet_GetSmartBest).*' \
-count=1 2>&1 | tee "$tmp1"
go test ./control -race \
-run 'Test(RouteDialTcpRetry.*|ContainsDialer|RelayTCP_Cancellation)' \
-count=1 2>&1 | tee "$tmp2"
if grep -q "no tests to run" "$tmp1" || grep -q "no tests to run" "$tmp2"; then
echo "SCI-3 failed: no tests to run"
exit 1
fi

report:
name: SCI PR Comment
if: always() && github.event_name == 'pull_request'
needs: [sci-1, sci-2, sci-3]
runs-on: ubuntu-22.04
permissions:
contents: read
pull-requests: write
steps:
- name: Update PR Comment
uses: actions/github-script@v7
env:
SCI1_RESULT: ${{ needs.sci-1.result }}
SCI2_RESULT: ${{ needs.sci-2.result }}
SCI3_RESULT: ${{ needs.sci-3.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
with:
script: |
const marker = "<!-- dae-policy-smart-sci -->";
const toIcon = (result) => {
switch (result) {
case "success":
return "✅";
case "failure":
return "❌";
case "cancelled":
return "⚪";
case "skipped":
return "⏭️";
default:
return "❔";
}
};

const jobs = [
{ name: "SCI-1 Strategy Correctness", result: process.env.SCI1_RESULT || "unknown" },
{ name: "SCI-2 TCP Retry Semantics", result: process.env.SCI2_RESULT || "unknown" },
{ name: "SCI-3 Concurrency Race", result: process.env.SCI3_RESULT || "unknown" },
];

const allSuccess = jobs.every((j) => j.result === "success");
const summary = allSuccess ? "pass" : "not pass";
const body = [
marker,
"## Policy Smart SCI",
"",
`- Summary: **${summary}**`,
`- Workflow run: ${process.env.RUN_URL}`,
"",
"### Job Status",
"",
...jobs.map((j) => `- ${toIcon(j.result)} ${j.name}: \`${j.result}\``),
].join("\n");

const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});

const existing = comments.find((c) => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
2 changes: 2 additions & 0 deletions common/consts/dialer.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const (
DialerSelectionPolicy_MinAverage10Latencies DialerSelectionPolicy = "min_avg10"
// DialerSelectionPolicy_MinMovingAverageLatencies selects the dialer with minimum moving average latency.
DialerSelectionPolicy_MinMovingAverageLatencies DialerSelectionPolicy = "min_moving_avg"
// DialerSelectionPolicy_Smart selects dialer by moving average latency and penalty score.
DialerSelectionPolicy_Smart DialerSelectionPolicy = "smart"
// DialerSelectionPolicy_MinLastLatency selects the dialer with minimum last latency.
DialerSelectionPolicy_MinLastLatency DialerSelectionPolicy = "min"
)
Expand Down
32 changes: 32 additions & 0 deletions component/outbound/dialer/alive_dialer_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package dialer

import (
"fmt"
"math"
"sort"
"strings"
"sync"
Expand Down Expand Up @@ -117,6 +118,33 @@ func (a *AliveDialerSet) GetMinLatency() (d *Dialer, latency time.Duration) {
return a.minLatency.dialer, a.minLatency.sortingLatency
}

func (a *AliveDialerSet) GetSmartBest() (*Dialer, time.Duration) {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.inorderedAliveDialerSet) == 0 {
return nil, 0
}
var bestDialer *Dialer
bestScore := time.Duration(math.MaxInt64)
for _, d := range a.inorderedAliveDialerSet {
rawLatency, ok := a.dialerToLatency[d]
if !ok {
continue
}
sortingLatency := rawLatency + a.dialerToLatencyOffset[d]
col := d.mustGetCollection(a.CheckTyp)
score := time.Duration(float64(sortingLatency) * (1 + col.PenaltyPoints))
if bestDialer == nil || score < bestScore {
bestDialer = d
bestScore = score
}
}
if bestDialer == nil {
return nil, 0
}
return bestDialer, bestScore
}

func (a *AliveDialerSet) printLatencies() {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("Group '%v' [%v]:\n", a.dialerGroupName, a.CheckTyp.String()))
Expand Down Expand Up @@ -168,6 +196,10 @@ func (a *AliveDialerSet) NotifyLatencyChange(dialer *Dialer, alive bool) {
rawLatency = dialer.mustGetCollection(a.CheckTyp).MovingAverage
hasLatency = rawLatency > 0
minPolicy = true
case consts.DialerSelectionPolicy_Smart:
rawLatency = dialer.mustGetCollection(a.CheckTyp).MovingAverage
hasLatency = rawLatency > 0
minPolicy = true
}

if alive {
Expand Down
94 changes: 94 additions & 0 deletions component/outbound/dialer/alive_dialer_set_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* SPDX-License-Identifier: AGPL-3.0-only
* Copyright (c) 2022-2025, daeuniverse Organization <dae@v2raya.org>
*/

package dialer

import (
"testing"
"time"

"github.com/daeuniverse/dae/common/consts"
)

func newSmartTestNetworkType() *NetworkType {
return &NetworkType{
L4Proto: consts.L4ProtoStr_TCP,
IpVersion: consts.IpVersionStr_4,
IsDns: false,
}
}

func TestAliveDialerSet_GetSmartBest_PrefersLowerEffectiveLatency(t *testing.T) {
networkType := newSmartTestNetworkType()
d1 := newNamedTestDialer(t, "smart-d1")
d2 := newNamedTestDialer(t, "smart-d2")

set := NewAliveDialerSet(
d1.Log,
"smart-group",
networkType,
0,
consts.DialerSelectionPolicy_Smart,
[]*Dialer{d1, d2},
[]*Annotation{{}, {}},
func(bool) {},
true,
)

col1 := d1.mustGetCollection(networkType)
col1.MovingAverage = 100 * time.Millisecond
col1.PenaltyPoints = 4.0 // effective = 500ms
set.NotifyLatencyChange(d1, true)

col2 := d2.mustGetCollection(networkType)
col2.MovingAverage = 130 * time.Millisecond
col2.PenaltyPoints = 0.0 // effective = 130ms
set.NotifyLatencyChange(d2, true)

best, score := set.GetSmartBest()
if best == nil {
t.Fatal("expected non-nil best dialer")
}
if best != d2 {
t.Fatalf("unexpected best dialer: got=%s want=%s", best.Property().Name, d2.Property().Name)
}
if score != 130*time.Millisecond {
t.Fatalf("unexpected best score: got=%v want=%v", score, 130*time.Millisecond)
}
}

func TestAliveDialerSet_GetSmartBest_EffectiveLatencyOverflowBoundary(t *testing.T) {
networkType := newSmartTestNetworkType()
d := newNamedTestDialer(t, "smart-overflow")

set := NewAliveDialerSet(
d.Log,
"smart-group",
networkType,
0,
consts.DialerSelectionPolicy_Smart,
[]*Dialer{d},
[]*Annotation{{}},
func(bool) {},
true,
)

col := d.mustGetCollection(networkType)
col.MovingAverage = Timeout
col.PenaltyPoints = maxPenaltyPoints
set.NotifyLatencyChange(d, true)

best, score := set.GetSmartBest()
if best != d {
t.Fatal("expected the only dialer to be selected")
}
want := time.Duration(float64(Timeout) * (1 + maxPenaltyPoints))
if score != want {
t.Fatalf("unexpected effective latency: got=%v want=%v", score, want)
}
if score <= 0 {
t.Fatalf("effective latency should be positive, got=%v", score)
}
}
Loading
Loading