diff --git a/frontend/src/pages/xray/balancers/balancer-helpers.ts b/frontend/src/pages/xray/balancers/balancer-helpers.ts index 44e10e180..31eef3a9a 100644 --- a/frontend/src/pages/xray/balancers/balancer-helpers.ts +++ b/frontend/src/pages/xray/balancers/balancer-helpers.ts @@ -26,14 +26,23 @@ export function collectSelectors(list: BalancerObject[]): string[] { } // syncObservatories keeps the (burst)observatory sections aligned with the -// balancer strategies that actually require them. Observatories have no -// runtime reload API in xray-core, so any change here forces a full process -// restart — that's why random/roundRobin balancers, which work fine without -// an observer, never CREATE one: a plain balancer add/edit then stays a -// routing-only change and applies live through the core API. An already -// existing burstObservatory is still kept in sync for them (alive-only -// filtering keeps working for setups that had it), it's just never the -// reason a new one appears. +// balancer strategies that actually require them. Observatories have no runtime +// reload API in xray-core, so creating OR removing one forces a full process +// restart — that's why an observer-less balancer never gets one and stays a +// live, routing-only change applied through the core API. +// +// xray-core binds the Observatory feature to a Random/RoundRobinStrategy only +// when its fallbackTag is set (issue #5605): with a fallbackTag the strategy +// calls RequireFeatures(Observatory) and the core aborts startup with "not all +// dependencies are resolved" if none exists; without a fallbackTag it never even +// consults an observatory. leastLoad always needs the burst observer, leastPing +// the regular one. +// +// So each observer lives exactly as long as something requires it, and is +// dropped the moment nothing does — clearing the last fallbackTag (or deleting +// the last leastLoad) removes the burst observer again. A no-fallback balancer's +// selector is still probed while the observer exists for another reason, but +// never keeps it alive on its own. export function syncObservatories(t: XraySettingsValue) { const balancers = (t.routing?.balancers || []) as BalancerObject[]; @@ -45,15 +54,20 @@ export function syncObservatories(t: XraySettingsValue) { delete t.observatory; } - const required = balancers.filter((b) => b.strategy?.type === 'leastLoad'); + const hasFallback = (b: BalancerObject) => (b.fallbackTag ?? '').length > 0; + const required = balancers.filter((b) => { + const type = b.strategy?.type || 'random'; + if (type === 'leastLoad') return true; + return (type === 'random' || type === 'roundRobin') && hasFallback(b); + }); const optional = balancers.filter((b) => { const type = b.strategy?.type || 'random'; - return type === 'random' || type === 'roundRobin'; + return (type === 'random' || type === 'roundRobin') && !hasFallback(b); }); - if (required.length > 0 || (optional.length > 0 && t.burstObservatory)) { + if (required.length > 0) { if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY)); (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]); - } else if (required.length === 0 && optional.length === 0) { + } else { delete t.burstObservatory; } } diff --git a/frontend/src/test/balancer-observatory-sync.test.ts b/frontend/src/test/balancer-observatory-sync.test.ts index d4d34b736..08c97c784 100644 --- a/frontend/src/test/balancer-observatory-sync.test.ts +++ b/frontend/src/test/balancer-observatory-sync.test.ts @@ -10,7 +10,8 @@ function tpl(routing: Record, extra: Record = // Observatory sections have no reload API in xray-core, so creating one turns // a balancer save from a live (hot-applied) routing change into a full // restart. These tests pin the rule: only strategies that genuinely need an -// observer may create one. +// observer may create one — which, for random/roundRobin, means a fallbackTag +// is set (xray-core then requires the Observatory feature; see #5605). describe('syncObservatories', () => { it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] }); @@ -19,12 +20,65 @@ describe('syncObservatories', () => { expect(t.observatory).toBeUndefined(); }); - it('does not create burstObservatory for roundRobin', () => { + it('does not create burstObservatory for roundRobin without fallback', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] }); syncObservatories(t); expect(t.burstObservatory).toBeUndefined(); }); + it('creates burstObservatory for a random balancer with a fallbackTag (#5605)', () => { + const t = tpl({ balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }] }); + syncObservatories(t); + expect(t.burstObservatory).toBeDefined(); + expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['opera-proxy']); + }); + + it('creates burstObservatory for roundRobin with a fallbackTag', () => { + const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } }] }); + syncObservatories(t); + expect(t.burstObservatory).toBeDefined(); + expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']); + }); + + it('treats an empty-string fallbackTag as no fallback (stays hot-appliable)', () => { + const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: '' }] }); + syncObservatories(t); + expect(t.burstObservatory).toBeUndefined(); + }); + + it('removes burstObservatory when a random balancer drops its fallbackTag', () => { + const t = tpl( + { balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: '' }] }, + { burstObservatory: { subjectSelector: ['opera-proxy'] } }, + ); + syncObservatories(t); + expect(t.burstObservatory).toBeUndefined(); + }); + + it('removes burstObservatory when a roundRobin balancer drops its fallbackTag', () => { + const t = tpl( + { balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] }, + { burstObservatory: { subjectSelector: ['a'] } }, + ); + syncObservatories(t); + expect(t.burstObservatory).toBeUndefined(); + }); + + it('keeps burstObservatory while another fallback balancer still needs it', () => { + const t = tpl( + { + balancers: [ + { tag: 'b1', selector: ['a'] }, + { tag: 'b2', selector: ['b'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } }, + ], + }, + { burstObservatory: { subjectSelector: ['a', 'b'] } }, + ); + syncObservatories(t); + expect(t.burstObservatory).toBeDefined(); + expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']); + }); + it('creates burstObservatory for leastLoad (required by the strategy)', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] }); syncObservatories(t);