Fix connectivity staleness, distinct checking dot, read-only groups, poll/probe robustness

This commit is contained in:
av
2026-07-18 16:45:36 +03:00
parent ec673b92f3
commit 7b08568d2c
4 changed files with 87 additions and 32 deletions
+70 -20
View File
@@ -35,7 +35,9 @@ class SingBoxIndicator extends PanelMenu.Button {
this._delays = new Map(); // node name -> ms number | 'timeout'
this._probing = new Set(); // nodes with a latency probe in flight
this._apiUrl = '';
this._apiSecret = '';
this._polling = false;
this._pollAgain = false; // a refresh arrived while polling
this._pinging = false; // connectivity probe in flight
this._settingsDebounceId = 0;
this._cancellable = new Gio.Cancellable();
@@ -59,10 +61,10 @@ class SingBoxIndicator extends PanelMenu.Button {
});
// Monochrome status dot in the panel's text color (adapts to theme):
// filled disc — service up and outbound has connectivity;
// bright ring — service up but no connectivity through the outbound;
// dim ring — service up, connectivity not yet measured;
// dashed dim ring — service (Clash API) unreachable / connecting.
// filled bright disc — service up and outbound has connectivity;
// filled dim disc — service up, connectivity not yet measured;
// bright ring — service up but no connectivity through the outbound;
// dashed dim ring — service (Clash API) unreachable / connecting.
this._dot = new St.DrawingArea({
style_class: 'sbx-dot',
y_align: Clutter.ActorAlign.CENTER,
@@ -149,8 +151,15 @@ class SingBoxIndicator extends PanelMenu.Button {
_reconfigure() {
// Latencies measured against one instance are meaningless for another.
if (this._settings.get_string('api-url') !== this._apiUrl)
// If the endpoint (URL or secret) changed, drop cached latencies and
// rotate the cancellable so stale old-endpoint responses are discarded.
const urlChanged = this._settings.get_string('api-url') !== this._apiUrl;
const secretChanged = this._settings.get_string('api-secret') !== this._apiSecret;
if (urlChanged || secretChanged) {
this._delays.clear();
this._cancellable.cancel();
this._cancellable = new Gio.Cancellable();
}
this._applySettings();
this._restartPolling();
this._restartConnectivityCheck();
@@ -164,9 +173,10 @@ class SingBoxIndicator extends PanelMenu.Button {
_applySettings() {
this._apiUrl = this._settings.get_string('api-url');
this._apiSecret = this._settings.get_string('api-secret');
this._api.setEndpoint(
this._apiUrl,
this._settings.get_string('api-secret')
this._apiSecret
);
this._interval = this._settings.get_int('refresh-interval');
this._showOutbound = this._settings.get_boolean('show-outbound');
@@ -209,8 +219,10 @@ class SingBoxIndicator extends PanelMenu.Button {
}
async _poll() {
if (this._polling)
if (this._polling) {
this._pollAgain = true;
return;
}
this._polling = true;
const c = this._cancellable;
try {
@@ -236,14 +248,24 @@ class SingBoxIndicator extends PanelMenu.Button {
return;
this._apiUp = true;
// Note: _updateFromData is UI code — keep it out of the API try so
// a rendering bug isn't misreported as "API unreachable".
this._updateFromData(conn, proxies);
// a rendering bug isn't misreported as "API unreachable". A render
// failure is logged but must not skip status/connectivity handling.
try {
this._updateFromData(conn, proxies);
} catch (e) {
logError(e, 'sing-box-status: render failed');
}
this._updateStatus();
// Resolve unknown connectivity promptly (startup, recovery, switch).
if (this._checkConnectivity && this._connectivity === 'unknown')
this._pingConnectivity();
} finally {
this._polling = false;
// A refresh arrived mid-poll and was swallowed by the guard: run it.
if (this._pollAgain) {
this._pollAgain = false;
this._poll();
}
}
}
@@ -304,11 +326,17 @@ class SingBoxIndicator extends PanelMenu.Button {
} catch (e) {
if (c.is_cancelled() || node !== this._activeOutbound)
return;
// A transport/API failure is not an outbound-connectivity verdict.
this._connectivity = this._apiUp ? 'down' : 'unknown';
this._connectivityDelay = null;
if (this._apiUp)
// An HTTP error is a real outbound-failure verdict; a transport/API
// error (unreachable, aborted) is not — don't record it as a timeout.
const httpFail = typeof e?.message === 'string' && e.message.startsWith('HTTP');
if (httpFail) {
this._connectivity = 'down';
this._connectivityDelay = null;
this._delays.set(node, 'timeout');
} else {
this._connectivity = 'unknown';
this._connectivityDelay = null;
}
this._updateStatus();
} finally {
this._pinging = false;
@@ -393,15 +421,26 @@ class SingBoxIndicator extends PanelMenu.Button {
if (!p || !Array.isArray(p.all))
continue;
if (p.type === 'Selector')
groups.push({ name, now: p.now, all: p.all, switchable: true });
groups.push({ name, now: p.now, all: p.all, type: p.type, switchable: true });
else if (p.type === 'URLTest' || p.type === 'Fallback')
groups.push({ name, now: p.now, all: p.all, switchable: false });
groups.push({ name, now: p.now, all: p.all, type: p.type, switchable: false });
}
// Switchable (Selector) groups first; sort is stable within each class.
groups.sort((a, b) => (b.switchable ? 1 : 0) - (a.switchable ? 1 : 0));
// Panel outbound label: chosen group's current node.
const panelGroup = this._pickPanelGroup(groups);
this._outboundLabel.text = panelGroup ? panelGroup.now ?? '' : '';
this._activeOutbound = panelGroup ? panelGroup.now ?? null : null;
const newActive = panelGroup ? panelGroup.now ?? null : null;
// If the active outbound changed outside _selectOutbound (e.g. a
// URLTest re-pick or an external switch), the last connectivity verdict
// is stale — reset it so the poll-side re-ping re-probes the new node.
if (newActive !== this._activeOutbound && this._checkConnectivity && newActive) {
this._connectivity = 'unknown';
this._connectivityDelay = null;
}
this._activeOutbound = newActive;
// Rebuild the group section only when the structure changes.
const signature = JSON.stringify(groups.map(g => [g.name, g.all, g.switchable]));
@@ -415,7 +454,7 @@ class SingBoxIndicator extends PanelMenu.Button {
const stored = this._selectorItems.get(g.name);
if (!stored)
continue;
stored.subMenu.label.text = `${g.name}: ${g.now ?? '—'}`;
stored.subMenu.label.text = this._groupTitle(g);
for (const [member, entry] of stored.members) {
entry.setOrnament(member === g.now
? PopupMenu.Ornament.DOT
@@ -442,7 +481,7 @@ class SingBoxIndicator extends PanelMenu.Button {
}
for (const g of groups) {
const subMenu = new PopupMenu.PopupSubMenuMenuItem(`${g.name}: ${g.now ?? '—'}`, true);
const subMenu = new PopupMenu.PopupSubMenuMenuItem(this._groupTitle(g), true);
subMenu.icon.icon_name = 'network-workgroup-symbolic';
// Manual latency probe for all nodes in this group. Override
@@ -479,6 +518,13 @@ class SingBoxIndicator extends PanelMenu.Button {
this._connItem.label.text = `${_('Connections')}: —`;
}
// Submenu title: read-only groups also show their type (URLTest/Fallback).
_groupTitle(g) {
return g.switchable
? `${g.name}: ${g.now ?? '—'}`
: `${g.name} [${g.type}]: ${g.now ?? '—'}`;
}
_pickPanelGroup(groups) {
if (groups.length === 0)
return null;
@@ -524,6 +570,9 @@ class SingBoxIndicator extends PanelMenu.Button {
if (!stored)
return;
for (const [member, item] of stored.members) {
// Skip nodes already being probed (manual re-click or overlap).
if (this._probing.has(member))
continue;
this._probing.add(member);
item.label.text = `${member} · …`;
this._api.getDelay(member, { url: this._connectivityUrl || undefined }, this._cancellable)
@@ -581,7 +630,7 @@ class SingBoxIndicator extends PanelMenu.Button {
this._outboundLabel.text = '';
this._speedLabel.text = '';
this._speedItem.label.text = _('API unreachable — check URL and secret in Settings');
} else if (this._checkConnectivity && this._connectivity === 'unknown') {
} else if (this._checkConnectivity && this._activeOutbound && this._connectivity === 'unknown') {
// Service up, connectivity not measured yet — don't claim it works.
dot = 'checking';
this._statusItem.label.text = _('sing-box · Active (checking…)');
@@ -631,8 +680,9 @@ class SingBoxIndicator extends PanelMenu.Button {
cr.stroke();
break;
case 'checking':
// Dim solid ring: service up, connectivity not measured yet.
// Dim filled disc: service up, connectivity not measured yet.
cr.setSourceRGBA(r, g, b, a * 0.5);
cr.fillPreserve();
cr.stroke();
break;
default: