Release 3.8.0.1: unify apphost, harden lifecycle and core SHA checks.

This commit is contained in:
M4
2026-07-29 21:43:41 +03:00
parent 64c097d1e7
commit 54b5b87990
26 changed files with 534 additions and 619 deletions
+27 -3
View File
@@ -33,6 +33,8 @@ type Manager struct {
profile *config.Profile
stderr io.Writer
binDir string
// lastStop tracks in-flight engine.Stop so Connect waits for ports to free.
lastStop sync.WaitGroup
}
func NewManager(cfgPath string, cfg *config.Config, stderr io.Writer) (*Manager, error) {
@@ -68,13 +70,25 @@ func (m *Manager) RecoverSystemProxy() {
sysproxy.ClearSentinel(m.cfgPath)
}
func (m *Manager) waitEngineStopped() {
m.lastStop.Wait()
}
func (m *Manager) Connect(ctx context.Context, profileName string) error {
// Wait outside the lock so a slow Disconnect can finish freeing :1080/:1081.
m.waitEngineStopped()
m.mu.Lock()
defer m.mu.Unlock()
if m.engine != nil && m.engine.Running() {
return fmt.Errorf("already connected; disconnect first")
}
if m.engine != nil {
// Defunct reference (stop finished or engine died) — drop before start.
m.engine = nil
m.profile = nil
}
if profileName != "" {
m.cfg.Active = profileName
@@ -122,6 +136,9 @@ func (m *Manager) Disconnect() error {
engine := m.engine
m.engine = nil
m.profile = nil
if engine != nil {
m.lastStop.Add(1)
}
m.mu.Unlock()
var first error
@@ -143,14 +160,17 @@ func (m *Manager) Disconnect() error {
}
if engine != nil {
done := make(chan error, 1)
go func() { done <- engine.Stop() }()
go func() {
defer m.lastStop.Done()
done <- engine.Stop()
}()
select {
case err := <-done:
if err != nil && first == nil {
first = err
}
case <-time.After(3 * time.Second):
// Engine Stop can block on userspace relays; don't freeze the UI.
// Stop continues in background; Connect will wait on lastStop.
if first == nil {
first = fmt.Errorf("отключение заняло слишком много времени")
}
@@ -236,10 +256,14 @@ func (m *Manager) BinDir() string { return m.binDir }
func (m *Manager) ConfigPath() string { return m.cfgPath }
// Config returns a deep snapshot for UI reads. Mutations must go through Manager methods.
func (m *Manager) Config() *config.Config {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
if m.cfg == nil {
return &config.Config{}
}
return m.cfg.Clone()
}
func (m *Manager) SetSystemProxy(enabled bool) {
+127
View File
@@ -0,0 +1,127 @@
package core
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"vpnclient/internal/config"
)
type fakeEngine struct {
mu sync.Mutex
running bool
stopDelay time.Duration
stops atomic.Int32
}
func (e *fakeEngine) Protocol() config.Protocol { return config.ProtocolNaive }
func (e *fakeEngine) Start(context.Context, config.Profile, string) error {
e.mu.Lock()
defer e.mu.Unlock()
e.running = true
return nil
}
func (e *fakeEngine) Stop() error {
e.stops.Add(1)
if e.stopDelay > 0 {
time.Sleep(e.stopDelay)
}
e.mu.Lock()
e.running = false
e.mu.Unlock()
return nil
}
func (e *fakeEngine) Running() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.running
}
func (e *fakeEngine) LocalHTTPProxy() (string, bool) { return "127.0.0.1:1081", true }
func (e *fakeEngine) LocalSOCKSProxy() (string, bool) { return "127.0.0.1:1080", true }
func TestDisconnectWaitsBeforeReconnect(t *testing.T) {
cfg := &config.Config{
Active: "t",
SystemProxy: false,
BinDir: t.TempDir(),
Profiles: []config.Profile{{
Name: "t",
Protocol: config.ProtocolNaive,
Proxy: "https://u:p@example.com",
Listen: []string{"socks://127.0.0.1:1080", "http://127.0.0.1:1081"},
}},
}
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
if err != nil {
t.Fatal(err)
}
eng := &fakeEngine{stopDelay: 200 * time.Millisecond}
m.mu.Lock()
m.engine = eng
m.profile = &cfg.Profiles[0]
m.mu.Unlock()
start := time.Now()
_ = m.Disconnect()
// Connect must wait for Stop to finish even if Disconnect returned early...
// our Disconnect waits up to 3s, so with 200ms it should complete.
if time.Since(start) < 150*time.Millisecond {
t.Fatalf("disconnect returned too fast")
}
if eng.Running() {
t.Fatal("engine still running")
}
// Simulate slow stop that outlives Disconnect timeout.
eng2 := &fakeEngine{stopDelay: 500 * time.Millisecond}
m.mu.Lock()
m.engine = eng2
m.profile = &cfg.Profiles[0]
m.mu.Unlock()
// Force short path: call the wait mechanism like Connect does.
m.mu.Lock()
engine := m.engine
m.engine = nil
m.profile = nil
m.lastStop.Add(1)
m.mu.Unlock()
go func() {
defer m.lastStop.Done()
_ = engine.Stop()
}()
waitStart := time.Now()
m.waitEngineStopped()
if time.Since(waitStart) < 400*time.Millisecond {
t.Fatalf("waitEngineStopped returned before stop finished")
}
if eng2.Running() {
t.Fatal("engine2 still running after wait")
}
}
func TestConfigReturnsClone(t *testing.T) {
cfg := &config.Config{
Active: "a",
Profiles: []config.Profile{{
Name: "a", Protocol: config.ProtocolNaive, Proxy: "https://x",
}},
}
m, err := NewManager(t.TempDir()+"/c.json", cfg, nil)
if err != nil {
t.Fatal(err)
}
snap := m.Config()
snap.Active = "mutated"
snap.Profiles[0].Proxy = "leaked"
if m.cfg.Active != "a" {
t.Fatal("Config() leaked mutable Active")
}
if m.cfg.Profiles[0].Proxy != "https://x" {
t.Fatal("Config() leaked mutable Profile")
}
}