Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
ci / test (macos-latest) (push) Canceled after 0s
ci / test (ubuntu-latest) (push) Canceled after 0s
ci / test (windows-latest) (push) Canceled after 0s

Delta getState by rev, pause when hidden, cancellable background loops,
hostCache bounds, unlock PollUI around engine status, corebin singleflight,
PingAll worker pool.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
M4
2026-08-01 16:13:59 +03:00
co-authored by Cursor
parent ff5d87ab9f
commit 2ad376b49e
25 changed files with 277 additions and 67 deletions
+2 -1
View File
@@ -254,7 +254,8 @@ https://evilfox.win/
- трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии; - трей (Windows), CI workflows, REQUIRE_CODESIGN для релиза; Android gated Naive/AWG + sync версии;
- 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении; - 3.8.2+2: иконка в macOS Dock — буква N цвета морской волны при подключении, исходная при отключении;
- 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone; - 3.8.2+3: poll без os.Stat ядер + кэш host; Darwin sysproxy batch; Connect без Config.Clone;
- 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`). - 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP `/api` (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (`Navis-x.y.z.b.exe`);
- 3.8.2+5: delta-poll UI (`getState` по rev); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на `Engine.Running`; singleflight corebin; PingAll worker-pool.
В 3.8.1: В 3.8.1:
- меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера; - меньше нагрузка в простое: лёгкий опрос интерфейса без полного клонирования конфига, кэш путей к ядрам, лог с лимитом размера;
+2 -2
View File
@@ -13,8 +13,8 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
// versionCode = major*1_000_000 + minor*10_000 + patch*100 + build // versionCode = major*1_000_000 + minor*10_000 + patch*100 + build
versionCode = 3080204 versionCode = 3080205
versionName = "3.8.2+4" versionName = "3.8.2+5"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
+3 -3
View File
@@ -42,11 +42,11 @@ if errorlevel 1 exit /b 1
go build -o "tools\packmac\packmac.exe" .\tools\packmac go build -o "tools\packmac\packmac.exe" .\tools\packmac
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.4 -arch arm64 tools\packmac\packmac.exe -bin "dist\navis-release\darwin-arm64\Navis" -out "dist\navis-release\darwin-arm64" -version 3.8.2 -build 3.8.2.5 -arch arm64
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.4 -arch amd64 tools\packmac\packmac.exe -bin "dist\navis-release\darwin-amd64\Navis" -out "dist\navis-release\darwin-amd64" -version 3.8.2 -build 3.8.2.5 -arch amd64
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.4 -arch universal tools\packmac\packmac.exe -bin "dist\navis-release\darwin-universal\Navis" -out "dist\navis-release\darwin-universal" -version 3.8.2 -build 3.8.2.5 -arch universal
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
echo Built Mac GUI + CLI: echo Built Mac GUI + CLI:
+2
View File
@@ -93,11 +93,13 @@ func main() {
sig := make(chan os.Signal, 1) sig := make(chan os.Signal, 1)
signal.Notify(sig, shutdownSignals()...) signal.Notify(sig, shutdownSignals()...)
<-sig <-sig
a.Shutdown()
_ = mgr.Disconnect() _ = mgr.Disconnect()
_ = srv.Close() _ = srv.Close()
return return
} }
defer func() { defer func() {
a.Shutdown()
_ = mgr.Disconnect() _ = mgr.Disconnect()
_ = srv.Close() _ = srv.Close()
w.Destroy() w.Destroy()
Binary file not shown.
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -1,16 +1,16 @@
{ {
"version": "3.8.2", "version": "3.8.2",
"notes": "Navis 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).", "notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"mandatory": false, "mandatory": false,
"platforms": { "platforms": {
"windows-amd64": { "windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -1,16 +1,16 @@
{ {
"version": "3.8.2", "version": "3.8.2",
"notes": "Navis 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).", "notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"mandatory": false, "mandatory": false,
"platforms": { "platforms": {
"windows-amd64": { "windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
+1
View File
@@ -8,6 +8,7 @@ require (
github.com/diskfs/go-diskfs v1.9.4 github.com/diskfs/go-diskfs v1.9.4
github.com/ebitengine/purego v0.10.1 github.com/ebitengine/purego v0.10.1
golang.org/x/image v0.44.0 golang.org/x/image v0.44.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.43.0 golang.org/x/sys v0.43.0
) )
+2
View File
@@ -44,6 +44,8 @@ golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY= golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+79 -7
View File
@@ -3,6 +3,7 @@ package apphost
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -39,6 +40,8 @@ type App struct {
OpenURL func(string) error OpenURL func(string) error
OnAfterUpdate func() OnAfterUpdate func()
APIToken string APIToken string
ctx context.Context
cancel context.CancelFunc
} }
type UIState struct { type UIState struct {
@@ -63,6 +66,8 @@ type UIState struct {
ConnectOnLaunch bool `json:"connect_on_launch"` ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"` AutoReconnect bool `json:"auto_reconnect"`
LogTail string `json:"log_tail,omitempty"` LogTail string `json:"log_tail,omitempty"`
Rev string `json:"rev,omitempty"`
Unchanged bool `json:"unchanged,omitempty"`
} }
type PingBestResult struct { type PingBestResult struct {
@@ -74,10 +79,13 @@ type PingBestResult struct {
} }
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App { func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
ctx, cancel := context.WithCancel(context.Background())
return &App{ return &App{
Mgr: mgr, Mgr: mgr,
CfgPath: cfgPath, CfgPath: cfgPath,
LogBuf: logBuf, LogBuf: logBuf,
ctx: ctx,
cancel: cancel,
UpdateStatus: update.Status{ UpdateStatus: update.Status{
Current: update.DisplayVersion(), Current: update.DisplayVersion(),
ManifestURL: update.DefaultManifestURL, ManifestURL: update.DefaultManifestURL,
@@ -85,13 +93,40 @@ func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
} }
} }
// Shutdown cancels background loops (dock icon / watchdog). Safe to call more than once.
func (a *App) Shutdown() {
if a == nil || a.cancel == nil {
return
}
a.cancel()
}
func (a *App) GetState() (UIState, error) { func (a *App) GetState() (UIState, error) {
return a.getState(false) return a.GetStateSince("")
}
// GetStateSince returns a full poll snapshot, or {rev, unchanged:true} when since
// matches the current fingerprint (cheap idle HTTP polls).
func (a *App) GetStateSince(since string) (UIState, error) {
out, err := a.getState(false)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
if since != "" && since == out.Rev {
return UIState{Rev: out.Rev, Unchanged: true}, nil
}
return out, nil
} }
// GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls). // GetEditState returns full active proxy / hy2 secrets for the editor form (not for polls).
func (a *App) GetEditState() (UIState, error) { func (a *App) GetEditState() (UIState, error) {
return a.getState(true) out, err := a.getState(true)
if err != nil {
return out, err
}
out.Rev = fingerprintState(out)
return out, nil
} }
func (a *App) getState(includeSecrets bool) (UIState, error) { func (a *App) getState(includeSecrets bool) (UIState, error) {
@@ -155,6 +190,23 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
return out, nil return out, nil
} }
func fingerprintState(s UIState) string {
var b strings.Builder
b.Grow(256)
fmt.Fprintf(&b, "%t|%s|%s|%s|%t|%s|%t|%t|%t|%s|",
s.Connected, s.Profile, s.ActiveProfile, s.Protocol, s.CoreReady, s.LastError,
s.SystemProxy, s.ConnectOnLaunch, s.AutoReconnect, s.Subscription)
fmt.Fprintf(&b, "%s|%s|%t|", s.Update.Latest, s.Update.Current, s.Update.Available)
for _, p := range s.Profiles {
fmt.Fprintf(&b, "%s,%s,%s,%t;", p.Name, p.Protocol, p.Host, p.Active)
}
for _, p := range s.Pings {
fmt.Fprintf(&b, "%s,%t,%t,%d,%s;", p.Name, p.OK, p.Soft, p.Ms, p.Error)
}
sum := sha256.Sum256([]byte(b.String()))
return hex.EncodeToString(sum[:8])
}
func trimLogTail(s string, max int) string { func trimLogTail(s string, max int) string {
if max <= 0 || len(s) <= max { if max <= 0 || len(s) <= max {
return s return s
@@ -318,7 +370,11 @@ func (a *App) StartBackground() {
prefs := a.Mgr.Prefs() prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch { if prefs.ConnectOnLaunch {
go func() { go func() {
time.Sleep(1200 * time.Millisecond) select {
case <-a.ctx.Done():
return
case <-time.After(1200 * time.Millisecond):
}
if a.Mgr.Status().Connected { if a.Mgr.Status().Connected {
return return
} }
@@ -331,8 +387,13 @@ func (a *App) dockIconLoop() {
dockicon.SetConnected(false) dockicon.SetConnected(false)
var last bool var last bool
first := true first := true
t := time.NewTicker(400 * time.Millisecond)
defer t.Stop()
for { for {
time.Sleep(400 * time.Millisecond) select {
case <-a.ctx.Done():
return
case <-t.C:
up := a.Mgr.Status().Connected up := a.Mgr.Status().Connected
if first || up != last { if first || up != last {
first = false first = false
@@ -341,10 +402,16 @@ func (a *App) dockIconLoop() {
} }
} }
} }
}
func (a *App) watchdogLoop() { func (a *App) watchdogLoop() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for { for {
time.Sleep(2 * time.Second) select {
case <-a.ctx.Done():
return
case <-t.C:
if !a.Mgr.TakeUnexpectedDrop() { if !a.Mgr.TakeUnexpectedDrop() {
continue continue
} }
@@ -352,10 +419,15 @@ func (a *App) watchdogLoop() {
if !prefs.AutoReconnect { if !prefs.AutoReconnect {
continue continue
} }
time.Sleep(800 * time.Millisecond) select {
case <-a.ctx.Done():
return
case <-time.After(800 * time.Millisecond):
}
_ = a.Connect() _ = a.Connect()
} }
} }
}
func (a *App) InstallCore() (string, error) { func (a *App) InstallCore() (string, error) {
paths, err := a.Mgr.EnsureAllCores() paths, err := a.Mgr.EnsureAllCores()
@@ -602,7 +674,7 @@ func arg[T any](args []json.RawMessage, i int, def T) T {
func (a *App) dispatch(name string, args []json.RawMessage) (any, error) { func (a *App) dispatch(name string, args []json.RawMessage) (any, error) {
switch name { switch name {
case "getState": case "getState":
return a.GetState() return a.GetStateSince(arg(args, 0, ""))
case "getEditState": case "getEditState":
return a.GetEditState() return a.GetEditState()
case "connect": case "connect":
+27
View File
@@ -50,6 +50,33 @@ func TestHTTPBridgeGetState(t *testing.T) {
} }
} }
func TestHTTPBridgeGetStateUnchanged(t *testing.T) {
cfg := &config.Config{
Active: "demo",
Profiles: []config.Profile{
{Name: "demo", Protocol: config.ProtocolNaive, Proxy: "https://u:p@example.com"},
},
}
mgr, err := core.NewManager("", cfg, logbuf.New(0))
if err != nil {
t.Fatal(err)
}
a := New(mgr, "", logbuf.New(0))
a.APIToken = "test-token"
full, err := a.GetStateSince("")
if err != nil || full.Unchanged || full.Rev == "" {
t.Fatalf("full: unchanged=%v rev=%q err=%v", full.Unchanged, full.Rev, err)
}
again, err := a.GetStateSince(full.Rev)
if err != nil || !again.Unchanged || again.Rev != full.Rev {
t.Fatalf("delta: %+v err=%v", again, err)
}
if len(again.Profiles) != 0 {
t.Fatalf("unchanged payload should omit profiles, got %d", len(again.Profiles))
}
}
func TestHTTPBridgeRejectsBadToken(t *testing.T) { func TestHTTPBridgeRejectsBadToken(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
mgr, err := core.NewManager("", cfg, logbuf.New(0)) mgr, err := core.NewManager("", cfg, logbuf.New(0))
+19 -3
View File
@@ -1,4 +1,4 @@
// HTTP bridge for macOS GUI (Windows WebView2 already binds these as natives). // HTTP bridge for glaze GUI (Windows + macOS): POST /api/<method>.
(function () { (function () {
const methods = [ const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile", "getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
@@ -208,6 +208,7 @@
} }
let listFP = ""; let listFP = "";
let stateRev = "";
function profilesFingerprint(state) { function profilesFingerprint(state) {
const parts = [ const parts = [
@@ -444,13 +445,22 @@
} }
async function refresh(opts) { async function refresh(opts) {
const force = !!(opts && opts.force);
if (document.hidden && !force && !(opts && opts.syncForm)) {
return null;
}
const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty); const needSecrets = !!(opts && opts.syncForm) || (!formHydrated && !dirty);
let state; let state;
if (needSecrets && typeof getEditState === "function") { if (needSecrets && typeof getEditState === "function") {
state = await getEditState(); state = await getEditState();
} else { } else {
state = await getState(); state = await getState(stateRev || "");
} }
if (state && state.unchanged) {
if (state.rev) stateRev = state.rev;
return state;
}
if (state && state.rev) stateRev = state.rev;
paint(state, opts); paint(state, opts);
return state; return state;
} }
@@ -697,5 +707,11 @@
setMeta(String(e), "err"); setMeta(String(e), "err");
} }
})(); })();
setInterval(() => { if (!busy && !modal.classList.contains("open")) refresh().catch(() => {}); }, 4000); setInterval(() => {
if (document.hidden || busy || modal.classList.contains("open")) return;
refresh().catch(() => {});
}, 4000);
document.addEventListener("visibilitychange", () => {
if (!document.hidden && !busy) refresh({ force: true }).catch(() => {});
});
+1
View File
@@ -327,6 +327,7 @@ func WriteExample(path string) error {
// Save writes config as indented JSON (atomic replace). // Save writes config as indented JSON (atomic replace).
func Save(path string, cfg Config) error { func Save(path string, cfg Config) error {
InvalidateHostCache()
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return err return err
} }
+25
View File
@@ -18,6 +18,8 @@ type ProfileInfo struct {
// hostCache avoids re-parsing large AWG conf bodies on every UI poll. // hostCache avoids re-parsing large AWG conf bodies on every UI poll.
var hostCache sync.Map // proxy URI/conf → host string var hostCache sync.Map // proxy URI/conf → host string
const hostCacheMax = 2048
func cachedProxyHost(proxy string) string { func cachedProxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy) proxy = strings.TrimSpace(proxy)
if proxy == "" { if proxy == "" {
@@ -28,9 +30,32 @@ func cachedProxyHost(proxy string) string {
} }
h := proxyHost(proxy) h := proxyHost(proxy)
hostCache.Store(proxy, h) hostCache.Store(proxy, h)
// Soft bound: subscription churn can retain stale URI keys; drop all if oversized.
n := 0
over := false
hostCache.Range(func(_, _ any) bool {
n++
if n > hostCacheMax {
over = true
return false
}
return true
})
if over {
InvalidateHostCache()
hostCache.Store(proxy, h)
}
return h return h
} }
// InvalidateHostCache clears parsed host strings (call after profile mutations).
func InvalidateHostCache() {
hostCache.Range(func(k, _ any) bool {
hostCache.Delete(k)
return true
})
}
// ListProfiles returns UI-friendly profile summaries. // ListProfiles returns UI-friendly profile summaries.
func (c *Config) ListProfiles() []ProfileInfo { func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles)) out := make([]ProfileInfo, 0, len(c.Profiles))
+20 -10
View File
@@ -380,19 +380,20 @@ type UIPoll struct {
AutoReconnect bool AutoReconnect bool
} }
// PollUI gathers status + profile list under one lock without Config().Clone(). // PollUI gathers status + profile list without Config().Clone().
// Config/profile fields are copied under the manager lock; engine status is
// read after unlock so a slow Engine.Running cannot block config saves.
func (m *Manager) PollUI(includeSecrets bool) UIPoll { func (m *Manager) PollUI(includeSecrets bool) UIPoll {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock()
out := UIPoll{ out := UIPoll{
Status: Status{SystemProxy: m.sys.Enabled()},
BinDir: m.binDir, BinDir: m.binDir,
SystemProxy: false, SystemProxy: false,
Subscription: "", Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"}, Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
LastError: m.lastError, LastError: m.lastError,
} }
var eng Engine
var profCopy *config.Profile
if m.cfg != nil { if m.cfg != nil {
out.Active = m.cfg.Active out.Active = m.cfg.Active
out.SystemProxy = m.cfg.SystemProxy out.SystemProxy = m.cfg.SystemProxy
@@ -424,16 +425,25 @@ func (m *Manager) PollUI(includeSecrets bool) UIPoll {
} }
} }
} }
if m.engine != nil && m.engine.Running() { eng = m.engine
out.Status.Connected = true
if m.profile != nil { if m.profile != nil {
out.Status.Profile = m.profile.Name p := *m.profile
out.Status.Protocol = m.profile.Protocol profCopy = &p
} }
if hp, ok := m.engine.LocalHTTPProxy(); ok { sys := m.sys
m.mu.Unlock()
out.Status = Status{SystemProxy: sys.Enabled()}
if eng != nil && eng.Running() {
out.Status.Connected = true
if profCopy != nil {
out.Status.Profile = profCopy.Name
out.Status.Protocol = profCopy.Protocol
}
if hp, ok := eng.LocalHTTPProxy(); ok {
out.Status.HTTPProxy = hp out.Status.HTTPProxy = hp
} }
if sp, ok := m.engine.LocalSOCKSProxy(); ok { if sp, ok := eng.LocalSOCKSProxy(); ok {
out.Status.SOCKSProxy = sp out.Status.SOCKSProxy = sp
} }
} }
+14
View File
@@ -3,11 +3,14 @@ package corebin
import ( import (
"strings" "strings"
"sync" "sync"
"golang.org/x/sync/singleflight"
) )
var ( var (
mu sync.Mutex mu sync.Mutex
cache = map[string]string{} cache = map[string]string{}
group singleflight.Group
) )
// CacheKey identifies a resolved core binary for a binDir + protocol. // CacheKey identifies a resolved core binary for a binDir + protocol.
@@ -48,7 +51,12 @@ func Invalidate() {
// Resolve caches the result of fn under key. // Resolve caches the result of fn under key.
// Hot-path polls trust the cache until Invalidate (no per-poll os.Stat). // Hot-path polls trust the cache until Invalidate (no per-poll os.Stat).
// Embedded/virtual paths never touch the filesystem. // Embedded/virtual paths never touch the filesystem.
// Concurrent cold misses for the same key share one fn call (singleflight).
func Resolve(key string, fn func() (string, error)) (string, error) { func Resolve(key string, fn func() (string, error)) (string, error) {
if path, ok := Get(key); ok {
return path, nil
}
v, err, _ := group.Do(key, func() (any, error) {
if path, ok := Get(key); ok { if path, ok := Get(key); ok {
return path, nil return path, nil
} }
@@ -58,6 +66,12 @@ func Resolve(key string, fn func() (string, error)) (string, error) {
} }
Set(key, path) Set(key, path)
return path, nil return path, nil
})
if err != nil {
return "", err
}
path, _ := v.(string)
return path, nil
} }
// ProtoKey normalizes protocol to the shared binary cache key (xray covers vless/vmess/trojan). // ProtoKey normalizes protocol to the shared binary cache key (xray covers vless/vmess/trojan).
+33
View File
@@ -3,7 +3,10 @@ package corebin
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"sync"
"sync/atomic"
"testing" "testing"
"time"
) )
func TestResolveTrustsCacheWithoutStat(t *testing.T) { func TestResolveTrustsCacheWithoutStat(t *testing.T) {
@@ -35,6 +38,36 @@ func TestResolveTrustsCacheWithoutStat(t *testing.T) {
} }
} }
func TestResolveSingleflight(t *testing.T) {
Invalidate()
key := CacheKey(t.TempDir(), "naive")
var calls atomic.Int32
var started sync.WaitGroup
started.Add(1)
fn := func() (string, error) {
calls.Add(1)
started.Wait()
time.Sleep(20 * time.Millisecond)
return "embedded-test-core", nil
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := Resolve(key, fn); err != nil {
t.Errorf("resolve: %v", err)
}
}()
}
time.Sleep(10 * time.Millisecond)
started.Done()
wg.Wait()
if calls.Load() != 1 {
t.Fatalf("expected 1 cold resolve, got %d", calls.Load())
}
}
func TestVirtualEmbeddedAWG(t *testing.T) { func TestVirtualEmbeddedAWG(t *testing.T) {
Invalidate() Invalidate()
key := CacheKey("/bin", "awg") key := CacheKey("/bin", "awg")
+13 -7
View File
@@ -57,20 +57,26 @@ func PingAll(ctx context.Context, targets []Target) []Result {
if len(targets) < workers { if len(targets) < workers {
workers = len(targets) workers = len(targets)
} }
sem := make(chan struct{}, workers) jobs := make(chan int, len(targets))
for i := range targets {
jobs <- i
}
close(jobs)
var wg sync.WaitGroup var wg sync.WaitGroup
for i, t := range targets { for w := 0; w < workers; w++ {
wg.Add(1) wg.Add(1)
go func(i int, t Target) { go func() {
defer wg.Done() defer wg.Done()
sem <- struct{}{} for i := range jobs {
defer func() { <-sem }() t := targets[i]
if strings.TrimSpace(t.Proxy) == "" { if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"} out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
return continue
} }
out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy) out[i] = PingProfile(ctx, t.Name, t.Protocol, t.Proxy)
}(i, t) }
}()
} }
wg.Wait() wg.Wait()
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part, // BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build. // macOS CFBundleVersion suffix, Android versionCode low digits). Bump on every release build.
const BuildNumber = 4 const BuildNumber = 5
// DefaultManifestURL is the update feed (hosted in the project git repo). // DefaultManifestURL is the update feed (hosted in the project git repo).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json" const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"
+3 -3
View File
@@ -77,9 +77,9 @@ h = hashlib.sha256(binp.read_bytes()).hexdigest()
zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else "" zh = hashlib.sha256(zipp.read_bytes()).hexdigest() if zipp.exists() else ""
dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else "" dh = hashlib.sha256(dmgp.read_bytes()).hexdigest() if dmgp.exists() else ""
notes = ( notes = (
f"Navis {ver}+{build}: единый GUI Windows/macOS на glaze + HTTP /api " f"Navis {ver}+{build}: delta-poll UI (getState по rev); пауза опроса при скрытом окне; "
f"(вместо WebView2 Bind); автоинкремент номера сборки при compile; " f"cancel lifecycle dock/watchdog; invalidate/лимит hostCache; "
f"артефакты Windows с версией в имени (Navis-x.y.z.b.exe)." f"PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool."
) )
paths = [ paths = [
Path('dist/update.json'), Path('dist/update.json'),
+3 -3
View File
@@ -1,16 +1,16 @@
{ {
"version": "3.8.2", "version": "3.8.2",
"notes": "Navis 3.8.2+4: единый GUI Windows/macOS на glaze + HTTP /api (вместо WebView2 Bind); автоинкремент номера сборки при compile; артефакты Windows с версией в имени (Navis-x.y.z.b.exe).", "notes": "Navis 3.8.2+5: delta-poll UI (getState по rev, без полного JSON если без изменений); пауза опроса при скрытом окне; cancel lifecycle dock/watchdog; invalidate/лимит hostCache; PollUI без lock на Engine.Running; singleflight corebin; PingAll worker-pool.",
"platform": "windows-amd64", "platform": "windows-amd64",
"os": "windows", "os": "windows",
"arch": "amd64", "arch": "amd64",
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"mandatory": false, "mandatory": false,
"platforms": { "platforms": {
"windows-amd64": { "windows-amd64": {
"url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe", "url": "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/navis-release/Navis.exe",
"sha256": "d3c1ff19f14de16768680770391b9d5565e13508890f48f0c19e087ada94e9d9", "sha256": "68ce68b36f5027ef222b7aca2c74fc8529b852ea3f5e7cc7c1cdd71c5162a7b6",
"os": "windows", "os": "windows",
"arch": "amd64" "arch": "amd64"
}, },
+4 -4
View File
@@ -4,13 +4,13 @@
"Major": 3, "Major": 3,
"Minor": 8, "Minor": 8,
"Patch": 2, "Patch": 2,
"Build": 4 "Build": 5
}, },
"ProductVersion": { "ProductVersion": {
"Major": 3, "Major": 3,
"Minor": 8, "Minor": 8,
"Patch": 2, "Patch": 2,
"Build": 4 "Build": 5
}, },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
"FileFlags": "00", "FileFlags": "00",
@@ -21,12 +21,12 @@
"StringFileInfo": { "StringFileInfo": {
"CompanyName": "EvilFox", "CompanyName": "EvilFox",
"FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)", "FileDescription": "Navis — VPN client (Naive / Hy2 / AWG / VLESS / VMess / Trojan)",
"FileVersion": "3.8.2.4", "FileVersion": "3.8.2.5",
"InternalName": "Navis", "InternalName": "Navis",
"LegalCopyright": "Copyright (c) EvilFox", "LegalCopyright": "Copyright (c) EvilFox",
"OriginalFilename": "Navis.exe", "OriginalFilename": "Navis.exe",
"ProductName": "Navis", "ProductName": "Navis",
"ProductVersion": "3.8.2.4", "ProductVersion": "3.8.2.5",
"Comments": "Open-source VPN/proxy client. https://evilfox.win/" "Comments": "Open-source VPN/proxy client. https://evilfox.win/"
}, },
"VarFileInfo": { "VarFileInfo": {