Skip to content

Commit 4b80c65

Browse files
committed
Wire wake/sleep/path monitor
1 parent b9ce61a commit 4b80c65

4 files changed

Lines changed: 248 additions & 34 deletions

File tree

go/core.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
// StartCore(coreType, configContent, // boots the proxy core
1414
// tunFD, mtu) // with TUN attached
1515
//
16+
// While running:
17+
//
18+
// Suspend() // on NE sleep()
19+
// Resume() // on NE wake()
20+
// UpdateDefaultInterface(name, index, // on NWPathMonitor
21+
// expensive, ...) // update
22+
//
1623
// On teardown:
1724
//
1825
// StopAll()
@@ -40,6 +47,16 @@ var (
4047

4148
type coreRunner interface {
4249
stop() error
50+
// suspend pauses non-essential activity in the running core
51+
// (URL-test probes, keepalives, new-connection handling) until
52+
// resume is called. Cores with no native pause hook (xray) leave
53+
// this as a no-op.
54+
suspend()
55+
resume()
56+
// updateDefaultInterface tells the core that the device's default
57+
// network interface has changed. name == "" / index == -1 means
58+
// the device currently has no usable path.
59+
updateDefaultInterface(name string, index int32, isExpensive, isConstrained bool)
4360
}
4461

4562
func Version() string { return "Everywhere Core v0.2" }
@@ -85,6 +102,47 @@ func StartCore(coreType, configContent string, tunFD, mtu int) error {
85102
return nil
86103
}
87104

105+
// Suspend pauses non-essential activity of the running core. Call it
106+
// from NEPacketTunnelProvider.sleep() — without that hop, the Go cores
107+
// keep firing scheduled work (sing-box URL-test probes, mihomo new-
108+
// connection handling, wireguard keepalives) at full pace through iOS
109+
// device-sleep windows. Returns nil when no core is running.
110+
func Suspend() error {
111+
mu.Lock()
112+
defer mu.Unlock()
113+
if coreInstance == nil {
114+
return nil
115+
}
116+
coreInstance.suspend()
117+
return nil
118+
}
119+
120+
// Resume reverses Suspend. Call it from NEPacketTunnelProvider.wake().
121+
func Resume() error {
122+
mu.Lock()
123+
defer mu.Unlock()
124+
if coreInstance == nil {
125+
return nil
126+
}
127+
coreInstance.resume()
128+
return nil
129+
}
130+
131+
// UpdateDefaultInterface tells the running core the device's default
132+
// physical interface changed — drive it from an NWPathMonitor on the
133+
// Swift side. Pass `index = -1` and `name = ""` when there is no
134+
// usable path. Without these updates, outbound sockets pinned by Go
135+
// to a stale path keep retransmitting after a WiFi↔cellular handoff.
136+
func UpdateDefaultInterface(name string, index int32, isExpensive, isConstrained bool) error {
137+
mu.Lock()
138+
defer mu.Unlock()
139+
if coreInstance == nil {
140+
return nil
141+
}
142+
coreInstance.updateDefaultInterface(name, index, isExpensive, isConstrained)
143+
return nil
144+
}
145+
88146
// StopAll halts the running core. Teardown is detached: the upstream
89147
// libraries' close paths can each take seconds (Xray drains
90148
// outbounds, sing-box has a 10s/service timeout, mihomo cleans up

go/mihomo.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import (
44
"errors"
55
"syscall"
66

7+
"github.com/metacubex/mihomo/component/iface"
8+
"github.com/metacubex/mihomo/component/resolver"
79
"github.com/metacubex/mihomo/hub"
810
"github.com/metacubex/mihomo/hub/executor"
11+
"github.com/metacubex/mihomo/tunnel"
912
)
1013

1114
type mihomoRunner struct{}
@@ -15,6 +18,30 @@ func (m *mihomoRunner) stop() error {
1518
return nil
1619
}
1720

21+
// suspend stops mihomo's tunnel from accepting new TCP/UDP from the
22+
// gvisor stack — handleTCPConn/handleUDPConn check tunnel.Status() and
23+
// drop everything that isn't Running. Existing in-flight connections
24+
// keep going; only newly arriving packets are short-circuited until
25+
// resume.
26+
func (m *mihomoRunner) suspend() {
27+
tunnel.OnSuspend()
28+
}
29+
30+
func (m *mihomoRunner) resume() {
31+
tunnel.OnRunning()
32+
}
33+
34+
// updateDefaultInterface flushes mihomo's interface-address cache and
35+
// resets the resolver's connection pool. mihomo's dialer.DefaultInterface
36+
// is intentionally left empty — inside an NEPacketTunnelProvider iOS
37+
// already routes outbound sockets created in the extension through the
38+
// underlying physical interface, and pinning a specific name (the
39+
// NWPathMonitor's "en0"/"pdp_ip0") would race with iOS's own routing.
40+
func (m *mihomoRunner) updateDefaultInterface(_ string, _ int32, _, _ bool) {
41+
iface.FlushCache()
42+
resolver.ResetConnection()
43+
}
44+
1845
// startMihomo boots mihomo with a TUN inbound bound to the given FD.
1946
// mihomo's RawTun.FileDescriptor is the canonical knob for "use this
2047
// fd instead of opening utun yourself" (listener/sing_tun/server.go

go/singbox.go

Lines changed: 155 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"net/netip"
66
"os"
7+
"sync"
78
"syscall"
89

910
box "github.com/sagernet/sing-box"
@@ -16,10 +17,13 @@ import (
1617
"github.com/sagernet/sing/common/logger"
1718
"github.com/sagernet/sing/common/x/list"
1819
"github.com/sagernet/sing/service"
20+
"github.com/sagernet/sing/service/pause"
1921
)
2022

2123
type singBoxRunner struct {
22-
box *box.Box
24+
box *box.Box
25+
pauseManager pause.Manager
26+
platform *singBoxPlatform
2327
}
2428

2529
func (s *singBoxRunner) stop() error {
@@ -29,17 +33,46 @@ func (s *singBoxRunner) stop() error {
2933
return nil
3034
}
3135

36+
func (s *singBoxRunner) suspend() {
37+
if s.pauseManager != nil && !s.pauseManager.IsDevicePaused() {
38+
s.pauseManager.DevicePause()
39+
}
40+
}
41+
42+
func (s *singBoxRunner) resume() {
43+
if s.pauseManager != nil && s.pauseManager.IsDevicePaused() {
44+
s.pauseManager.DeviceWake()
45+
}
46+
}
47+
48+
func (s *singBoxRunner) updateDefaultInterface(name string, index int32, isExpensive, isConstrained bool) {
49+
if s.platform == nil || s.platform.monitor == nil {
50+
return
51+
}
52+
var nm adapter.NetworkManager
53+
if s.box != nil {
54+
nm = s.box.Network()
55+
}
56+
s.platform.monitor.updateDefault(nm, name, index, isExpensive, isConstrained)
57+
}
58+
3259
func startSingBox(configContent string, tunFD, _ int) (coreRunner, error) {
3360
// include.Context attaches the built-in inbound/outbound/endpoint/
3461
// DNS-transport/service registries to the context. Without it
3562
// box.New cannot resolve types declared in the JSON (socks,
3663
// direct, vmess, …) and start fails immediately.
3764
ctx := include.Context(context.Background())
3865

39-
// sing-box's tun inbound has no FileDescriptor field in its JSON
40-
// schema — the FD is plumbed via an adapter.PlatformInterface
41-
// whose OpenInterface is invoked while wiring the inbound.
42-
pi := &singBoxPlatform{tunFD: tunFD}
66+
// box.New runs pause.WithDefaultManager on its own copy of the
67+
// context, so the manager it installs is unreachable from out here.
68+
// Install one on our ctx up front and box.New will reuse it
69+
// (WithDefaultManager early-returns when a manager is already
70+
// registered), which lets us hand DevicePause/DeviceWake to the
71+
// NE's sleep/wake callbacks.
72+
ctx = pause.WithDefaultManager(ctx)
73+
pauseManager := service.FromContext[pause.Manager](ctx)
74+
75+
pi := newSingBoxPlatform(tunFD)
4376
ctx = service.ContextWith[adapter.PlatformInterface](ctx, pi)
4477

4578
options, err := json.UnmarshalExtendedContext[option.Options](ctx, []byte(configContent))
@@ -57,7 +90,7 @@ func startSingBox(configContent string, tunFD, _ int) (coreRunner, error) {
5790
_ = b.Close()
5891
return nil, err
5992
}
60-
return &singBoxRunner{box: b}, nil
93+
return &singBoxRunner{box: b, pauseManager: pauseManager, platform: pi}, nil
6194
}
6295

6396
// singBoxPlatform is the minimal adapter.PlatformInterface needed
@@ -70,6 +103,14 @@ func startSingBox(configContent string, tunFD, _ int) (coreRunner, error) {
70103
type singBoxPlatform struct {
71104
tunFD int
72105
myAddresses []netip.Addr
106+
monitor *singBoxInterfaceMonitor
107+
}
108+
109+
func newSingBoxPlatform(tunFD int) *singBoxPlatform {
110+
return &singBoxPlatform{
111+
tunFD: tunFD,
112+
monitor: newSingBoxInterfaceMonitor(),
113+
}
73114
}
74115

75116
func (p *singBoxPlatform) Initialize(_ adapter.NetworkManager) error { return nil }
@@ -99,46 +140,126 @@ func (p *singBoxPlatform) OpenInterface(options *tun.Options, _ option.TunPlatfo
99140

100141
func (p *singBoxPlatform) UsePlatformDefaultInterfaceMonitor() bool { return true }
101142
func (p *singBoxPlatform) CreateDefaultInterfaceMonitor(_ logger.Logger) tun.DefaultInterfaceMonitor {
102-
return &singBoxInterfaceMonitor{}
143+
return p.monitor
103144
}
104145

105-
func (p *singBoxPlatform) UsePlatformNetworkInterfaces() bool { return false }
146+
func (p *singBoxPlatform) UsePlatformNetworkInterfaces() bool { return false }
106147
func (p *singBoxPlatform) NetworkInterfaces() ([]adapter.NetworkInterface, error) { return nil, os.ErrInvalid }
107148

108149
func (p *singBoxPlatform) UnderNetworkExtension() bool { return true }
109150
func (p *singBoxPlatform) NetworkExtensionIncludeAllNetworks() bool { return false }
110151

111-
func (p *singBoxPlatform) ClearDNSCache() {}
112-
func (p *singBoxPlatform) RequestPermissionForWIFIState() error { return nil }
113-
func (p *singBoxPlatform) ReadWIFIState() adapter.WIFIState { return adapter.WIFIState{} }
114-
func (p *singBoxPlatform) SystemCertificates() []string { return nil }
152+
func (p *singBoxPlatform) ClearDNSCache() {}
153+
func (p *singBoxPlatform) RequestPermissionForWIFIState() error { return nil }
154+
func (p *singBoxPlatform) ReadWIFIState() adapter.WIFIState { return adapter.WIFIState{} }
155+
func (p *singBoxPlatform) SystemCertificates() []string { return nil }
115156

116157
func (p *singBoxPlatform) UsePlatformConnectionOwnerFinder() bool { return false }
117158
func (p *singBoxPlatform) FindConnectionOwner(_ *adapter.FindConnectionOwnerRequest) (*adapter.ConnectionOwner, error) {
118159
return nil, os.ErrInvalid
119160
}
120161

121-
func (p *singBoxPlatform) UsePlatformWIFIMonitor() bool { return false }
122-
func (p *singBoxPlatform) UsePlatformNotification() bool { return false }
123-
func (p *singBoxPlatform) SendNotification(_ *adapter.Notification) error { return nil }
124-
func (p *singBoxPlatform) MyInterfaceAddress() []netip.Addr { return p.myAddresses }
125-
126-
// singBoxInterfaceMonitor is a no-op DefaultInterfaceMonitor. The
127-
// network manager calls it when running with a platform interface,
128-
// but in NEPacketTunnelProvider we let iOS handle outbound routing
129-
// — sing-box does not need to bind sockets to a specific underlying
130-
// interface. Outbound auto-detect-interface options become no-ops.
131-
type singBoxInterfaceMonitor struct{}
132-
133-
func (*singBoxInterfaceMonitor) Start() error { return nil }
134-
func (*singBoxInterfaceMonitor) Close() error { return nil }
135-
func (*singBoxInterfaceMonitor) DefaultInterface() *control.Interface { return nil }
136-
func (*singBoxInterfaceMonitor) OverrideAndroidVPN() bool { return false }
137-
func (*singBoxInterfaceMonitor) AndroidVPNEnabled() bool { return false }
138-
func (*singBoxInterfaceMonitor) RegisterCallback(_ tun.DefaultInterfaceUpdateCallback) *list.Element[tun.DefaultInterfaceUpdateCallback] {
139-
return nil
162+
func (p *singBoxPlatform) UsePlatformWIFIMonitor() bool { return false }
163+
func (p *singBoxPlatform) UsePlatformNotification() bool { return false }
164+
func (p *singBoxPlatform) SendNotification(_ *adapter.Notification) error { return nil }
165+
func (p *singBoxPlatform) MyInterfaceAddress() []netip.Addr { return p.myAddresses }
166+
167+
// singBoxInterfaceMonitor is a tun.DefaultInterfaceMonitor driven from
168+
// outside Go — typically by an NWPathMonitor on the iOS side feeding
169+
// Evcore.UpdateDefaultInterface. Without this, sing-box's router never
170+
// learns that the underlying network changed and sockets pinned to a
171+
// stale path keep retransmitting until OS-level timeouts.
172+
//
173+
// Layout mirrors libbox's `platformDefaultInterfaceMonitor`
174+
// (experimental/libbox/monitor.go): refresh sing-box's cached
175+
// interface list, look the new default up by index, fan callbacks
176+
// out under a mutex. When the index is -1 we signal `no path` by
177+
// calling callbacks with a nil interface, which the network manager
178+
// translates into `pauseManager.NetworkPause`.
179+
type singBoxInterfaceMonitor struct {
180+
access sync.Mutex
181+
current *control.Interface
182+
myInterface string
183+
callbacks list.List[tun.DefaultInterfaceUpdateCallback]
184+
}
185+
186+
func newSingBoxInterfaceMonitor() *singBoxInterfaceMonitor {
187+
return &singBoxInterfaceMonitor{}
188+
}
189+
190+
func (m *singBoxInterfaceMonitor) Start() error { return nil }
191+
func (m *singBoxInterfaceMonitor) Close() error { return nil }
192+
func (m *singBoxInterfaceMonitor) OverrideAndroidVPN() bool { return false }
193+
func (m *singBoxInterfaceMonitor) AndroidVPNEnabled() bool { return false }
194+
195+
func (m *singBoxInterfaceMonitor) DefaultInterface() *control.Interface {
196+
m.access.Lock()
197+
defer m.access.Unlock()
198+
return m.current
199+
}
200+
201+
func (m *singBoxInterfaceMonitor) RegisterCallback(callback tun.DefaultInterfaceUpdateCallback) *list.Element[tun.DefaultInterfaceUpdateCallback] {
202+
m.access.Lock()
203+
defer m.access.Unlock()
204+
return m.callbacks.PushBack(callback)
140205
}
141-
func (*singBoxInterfaceMonitor) UnregisterCallback(_ *list.Element[tun.DefaultInterfaceUpdateCallback]) {
206+
207+
func (m *singBoxInterfaceMonitor) UnregisterCallback(element *list.Element[tun.DefaultInterfaceUpdateCallback]) {
208+
m.access.Lock()
209+
defer m.access.Unlock()
210+
m.callbacks.Remove(element)
211+
}
212+
213+
func (m *singBoxInterfaceMonitor) RegisterMyInterface(interfaceName string) {
214+
m.access.Lock()
215+
defer m.access.Unlock()
216+
m.myInterface = interfaceName
217+
}
218+
219+
func (m *singBoxInterfaceMonitor) MyInterface() string {
220+
m.access.Lock()
221+
defer m.access.Unlock()
222+
return m.myInterface
223+
}
224+
225+
// updateDefault sets the monitor's default interface and fans out a
226+
// callback. nm is used to refresh sing-box's cached interface list
227+
// and resolve the new default by index — when the manager is missing
228+
// (start-up race) or the lookup fails we still fire a synthetic
229+
// control.Interface so the router gets at least a name/index.
230+
func (m *singBoxInterfaceMonitor) updateDefault(nm adapter.NetworkManager, name string, index int32, _ bool, _ bool) {
231+
if nm != nil {
232+
_ = nm.UpdateInterfaces()
233+
}
234+
235+
var newInterface *control.Interface
236+
if index >= 0 {
237+
if nm != nil {
238+
if iif, err := nm.InterfaceFinder().ByIndex(int(index)); err == nil {
239+
newInterface = iif
240+
}
241+
}
242+
if newInterface == nil {
243+
newInterface = &control.Interface{Name: name, Index: int(index)}
244+
}
245+
}
246+
247+
m.access.Lock()
248+
old := m.current
249+
m.current = newInterface
250+
if newInterface != nil && old != nil && old.Index == newInterface.Index && old.Name == newInterface.Name {
251+
// Identical to the previous default — no need to fan callbacks
252+
// out and trigger a ResetNetwork.
253+
m.access.Unlock()
254+
return
255+
}
256+
callbacks := make([]tun.DefaultInterfaceUpdateCallback, 0, m.callbacks.Len())
257+
for el := m.callbacks.Front(); el != nil; el = el.Next() {
258+
callbacks = append(callbacks, el.Value)
259+
}
260+
m.access.Unlock()
261+
262+
for _, callback := range callbacks {
263+
callback(newInterface, 0)
264+
}
142265
}
143-
func (*singBoxInterfaceMonitor) RegisterMyInterface(_ string) {}
144-
func (*singBoxInterfaceMonitor) MyInterface() string { return "" }

go/xray.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ func (x *xrayRunner) stop() error {
2525
return nil
2626
}
2727

28+
// suspend / resume / updateDefaultInterface are no-ops: xray-core has
29+
// no public pause hook and no platform interface monitor. The Swift
30+
// side still calls them on every core to keep the bridge surface
31+
// uniform.
32+
func (x *xrayRunner) suspend() {}
33+
func (x *xrayRunner) resume() {}
34+
func (x *xrayRunner) updateDefaultInterface(_ string, _ int32, _, _ bool) {}
35+
2836
// startXray boots Xray with a TUN inbound bound to the given FD.
2937
// Xray's iOS TUN path reads the FD from the env var `xray.tun.fd`
3038
// (see proxy/tun/tun_darwin.go) — the JSON's tun inbound only

0 commit comments

Comments
 (0)