Release 3.8.2.5: cheaper idle UI poll and tighter hot paths.
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run

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
+91 -19
View File
@@ -3,6 +3,7 @@ package apphost
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
@@ -39,6 +40,8 @@ type App struct {
OpenURL func(string) error
OnAfterUpdate func()
APIToken string
ctx context.Context
cancel context.CancelFunc
}
type UIState struct {
@@ -63,6 +66,8 @@ type UIState struct {
ConnectOnLaunch bool `json:"connect_on_launch"`
AutoReconnect bool `json:"auto_reconnect"`
LogTail string `json:"log_tail,omitempty"`
Rev string `json:"rev,omitempty"`
Unchanged bool `json:"unchanged,omitempty"`
}
type PingBestResult struct {
@@ -74,10 +79,13 @@ type PingBestResult struct {
}
func New(mgr *core.Manager, cfgPath string, logBuf *logbuf.Buffer) *App {
ctx, cancel := context.WithCancel(context.Background())
return &App{
Mgr: mgr,
CfgPath: cfgPath,
LogBuf: logBuf,
ctx: ctx,
cancel: cancel,
UpdateStatus: update.Status{
Current: update.DisplayVersion(),
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) {
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).
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) {
@@ -155,6 +190,23 @@ func (a *App) getState(includeSecrets bool) (UIState, error) {
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 {
if max <= 0 || len(s) <= max {
return s
@@ -318,7 +370,11 @@ func (a *App) StartBackground() {
prefs := a.Mgr.Prefs()
if prefs.ConnectOnLaunch {
go func() {
time.Sleep(1200 * time.Millisecond)
select {
case <-a.ctx.Done():
return
case <-time.After(1200 * time.Millisecond):
}
if a.Mgr.Status().Connected {
return
}
@@ -331,29 +387,45 @@ func (a *App) dockIconLoop() {
dockicon.SetConnected(false)
var last bool
first := true
t := time.NewTicker(400 * time.Millisecond)
defer t.Stop()
for {
time.Sleep(400 * time.Millisecond)
up := a.Mgr.Status().Connected
if first || up != last {
first = false
last = up
dockicon.SetConnected(up)
select {
case <-a.ctx.Done():
return
case <-t.C:
up := a.Mgr.Status().Connected
if first || up != last {
first = false
last = up
dockicon.SetConnected(up)
}
}
}
}
func (a *App) watchdogLoop() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
time.Sleep(2 * time.Second)
if !a.Mgr.TakeUnexpectedDrop() {
continue
select {
case <-a.ctx.Done():
return
case <-t.C:
if !a.Mgr.TakeUnexpectedDrop() {
continue
}
prefs := a.Mgr.Prefs()
if !prefs.AutoReconnect {
continue
}
select {
case <-a.ctx.Done():
return
case <-time.After(800 * time.Millisecond):
}
_ = a.Connect()
}
prefs := a.Mgr.Prefs()
if !prefs.AutoReconnect {
continue
}
time.Sleep(800 * time.Millisecond)
_ = a.Connect()
}
}
@@ -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) {
switch name {
case "getState":
return a.GetState()
return a.GetStateSince(arg(args, 0, ""))
case "getEditState":
return a.GetEditState()
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) {
cfg := &config.Config{}
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 () {
const methods = [
"getState","getEditState","connect","disconnect","connectProfile","saveProfile","createProfile",
@@ -208,6 +208,7 @@
}
let listFP = "";
let stateRev = "";
function profilesFingerprint(state) {
const parts = [
@@ -444,13 +445,22 @@
}
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);
let state;
if (needSecrets && typeof getEditState === "function") {
state = await getEditState();
} 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);
return state;
}
@@ -697,5 +707,11 @@
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).
func Save(path string, cfg Config) error {
InvalidateHostCache()
if err := cfg.Validate(); err != nil {
return err
}
+25
View File
@@ -18,6 +18,8 @@ type ProfileInfo struct {
// hostCache avoids re-parsing large AWG conf bodies on every UI poll.
var hostCache sync.Map // proxy URI/conf → host string
const hostCacheMax = 2048
func cachedProxyHost(proxy string) string {
proxy = strings.TrimSpace(proxy)
if proxy == "" {
@@ -28,9 +30,32 @@ func cachedProxyHost(proxy string) string {
}
h := proxyHost(proxy)
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
}
// 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.
func (c *Config) ListProfiles() []ProfileInfo {
out := make([]ProfileInfo, 0, len(c.Profiles))
+20 -10
View File
@@ -380,19 +380,20 @@ type UIPoll struct {
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 {
m.mu.Lock()
defer m.mu.Unlock()
out := UIPoll{
Status: Status{SystemProxy: m.sys.Enabled()},
BinDir: m.binDir,
SystemProxy: false,
Subscription: "",
Hy2: Hy2Options{Congestion: "bbr", BBRProfile: "standard"},
LastError: m.lastError,
}
var eng Engine
var profCopy *config.Profile
if m.cfg != nil {
out.Active = m.cfg.Active
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
if m.profile != nil {
p := *m.profile
profCopy = &p
}
sys := m.sys
m.mu.Unlock()
out.Status = Status{SystemProxy: sys.Enabled()}
if eng != nil && eng.Running() {
out.Status.Connected = true
if m.profile != nil {
out.Status.Profile = m.profile.Name
out.Status.Protocol = m.profile.Protocol
if profCopy != nil {
out.Status.Profile = profCopy.Name
out.Status.Protocol = profCopy.Protocol
}
if hp, ok := m.engine.LocalHTTPProxy(); ok {
if hp, ok := eng.LocalHTTPProxy(); ok {
out.Status.HTTPProxy = hp
}
if sp, ok := m.engine.LocalSOCKSProxy(); ok {
if sp, ok := eng.LocalSOCKSProxy(); ok {
out.Status.SOCKSProxy = sp
}
}
+16 -2
View File
@@ -3,11 +3,14 @@ package corebin
import (
"strings"
"sync"
"golang.org/x/sync/singleflight"
)
var (
mu sync.Mutex
cache = map[string]string{}
group singleflight.Group
)
// CacheKey identifies a resolved core binary for a binDir + protocol.
@@ -48,15 +51,26 @@ func Invalidate() {
// Resolve caches the result of fn under key.
// Hot-path polls trust the cache until Invalidate (no per-poll os.Stat).
// 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) {
if path, ok := Get(key); ok {
return path, nil
}
path, err := fn()
v, err, _ := group.Do(key, func() (any, error) {
if path, ok := Get(key); ok {
return path, nil
}
path, err := fn()
if err != nil {
return "", err
}
Set(key, path)
return path, nil
})
if err != nil {
return "", err
}
Set(key, path)
path, _ := v.(string)
return path, nil
}
+33
View File
@@ -3,7 +3,10 @@ package corebin
import (
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
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) {
Invalidate()
key := CacheKey("/bin", "awg")
+16 -10
View File
@@ -57,20 +57,26 @@ func PingAll(ctx context.Context, targets []Target) []Result {
if len(targets) < workers {
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
for i, t := range targets {
for w := 0; w < workers; w++ {
wg.Add(1)
go func(i int, t Target) {
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
return
for i := range jobs {
t := targets[i]
if strings.TrimSpace(t.Proxy) == "" {
out[i] = Result{Name: t.Name, Error: "нет ссылки сервера"}
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()
+1 -1
View File
@@ -22,7 +22,7 @@ const CurrentVersion = "3.8.2"
// BuildNumber is the monotonic build within CurrentVersion (Windows FileVersion 4th part,
// 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).
const DefaultManifestURL = "https://git.evilfox.cc/test2/navi/raw/branch/main/dist/update.json"