From 8493f0bc4c750cde321883e3281de591d9d354ae Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:12:59 +0800 Subject: [PATCH 1/2] fix(strategy29): retain recent summary progress across visibility changes --- ...inance-strategy29-bollinger-development.md | 30 +++++-- scripts/binance-strategy29-bollinger.user.js | 41 ++++++--- .../core/remote-summary-client.js | 15 +++- .../dom/strategy29-summary-panel.js | 4 +- .../index.user.js | 2 +- .../remote-summary.js | 23 +++-- .../runtime.test.js | 51 +++++++++++- .../strategy29-summary-panel.test.js | 13 +++ .../remote-summary-client.test.js | 83 +++++++++++++++++-- .../remote-summary-controller.test.js | 28 +++++++ .../source-regressions.test.js | 2 +- test/unit/userscript-release-contract.test.js | 2 +- 12 files changed, 252 insertions(+), 42 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 08fd6d1..cbdf996 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -9,7 +9,7 @@ already-loaded native chart candles. The optional summary reads the authenticate loopback observer gateway; it does not call Binance market-data or account APIs, submit orders, rotate hidden charts, or add remote events as chart drawings. -Install Strategy29 0.2.0 with orderbook 2.7.199 or later, or use it alone. +Install Strategy29 0.2.1 with orderbook 2.7.199 or later, or use it alone. Do not combine it with the embedded observer in orderbook 2.7.198. After updating/disabling the old script, reload the page. An embedded observer is an explicit conflict: Strategy29 stops and displays an upgrade/reload notice. @@ -17,7 +17,7 @@ If the old script loads later, existing markers can remain because its private save owner can block safe cleanup. This is not a supported compatibility mode; Strategy29 never removes old-script or user drawings. -The orderbook runs in page context. Strategy29 0.2.0 runs in a Tampermonkey +The orderbook runs in page context. Strategy29 runs in a Tampermonkey sandbox so its gateway secret remains in private userscript storage, and passes `unsafeWindow` explicitly to the chart runtime. The orderbook registers a synchronous boolean drawing-busy predicate under @@ -56,13 +56,25 @@ Panel timestamps explicitly use `UTC+08` rather than inheriting the browser's ambient timezone. The browser polls status first and then consumes at most two event pages per -scheduled poll. A page may contain no matching events while still advancing the -global cursor; that progress is accepted. `cursor_expired` clears only the remote -event rows and resumes from the explicit server cursor. Route changes, page -hiding, disabling, and disposal abort the owned request. Every response checks -context ownership before changing the panel, so a late old-symbol response is -ignored. No extra recurring timer is installed: the existing one-second runtime -sample applies a five-second remote gate and keeps requests single-flight. +scheduled poll. A new route requests `mode=latest&limit=20` for its canonical +symbol: the server returns the latest retained sequences and a global increment +cursor from one SQLite snapshot. It does not scan retained global history to +fill the panel. Subsequent increments can contain no matching events while still +advancing that cursor. `cursor_expired` clears only remote rows and requests a new +latest snapshot. Rows are displayed and bounded by descending durable sequence, +independent of detection timestamps. This requires the server's explicit latest +query contract; a server rejecting it stops the remote context visibly. +Publish the gateway contract before the `0.2.1` client, then verify installed +source identity and reload before remote acceptance. Publication of either +component does not enable the observer, gateway, or notifications. + +Route changes, page hiding, disabling, and disposal abort the owned request. +Hiding retains the current client, cursor, and panel rows; resume uses a fresh +request owner and continues increments. Route/settings changes retire the old +context. Responses check their original abort signal before changing client state; +completion handlers also check request ownership, so an old request cannot publish +or clear the in-flight flag of resumed work. No extra recurring timer is installed: +the existing one-second runtime sample applies a five-second remote gate. Transport failures and a temporarily unavailable database remain retryable remote states. Authentication, request, JSON, and response-contract failures diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index fe6297d..106294c 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy29.bollinger // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.2.0 +// @version 0.2.1 // @author jackhai9 // @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* @@ -255,7 +255,10 @@ function buildEventsUrl(origin, canonicalSymbol, cursor) { const url = new URL("/v1/strategy29/events", origin); url.searchParams.set("symbol", canonicalSymbol); - if (cursor !== null) url.searchParams.set("cursor", String(cursor)); + if (cursor === null) { + url.searchParams.set("mode", "latest"); + url.searchParams.set("limit", "20"); + } else url.searchParams.set("cursor", String(cursor)); return url.href; } function createStrategy29SummaryClient({ @@ -280,6 +283,7 @@ } async function poll(signal) { const statusResponse = await perform(`${origin}/v1/strategy29/status`, signal); + if (signal.aborted) throw signal.reason; const statusBody = parseJsonResponse(statusResponse, "Strategy29 status"); if (statusResponse.status === 503) { validateStrategy29GatewayError(statusBody, 503); @@ -299,12 +303,13 @@ while (pages < maxPagesPerPoll) { const requestedCursor = cursor; const eventsResponse = await perform(buildEventsUrl(origin, canonicalSymbol, cursor), signal); + if (signal.aborted) throw signal.reason; const eventsBody = parseJsonResponse(eventsResponse, "Strategy29 events"); pages += 1; if (eventsResponse.status === 409) { const error = validateStrategy29GatewayError(eventsBody, 409); - cursor = error.oldest_cursor; - onCursorReset(cursor); + cursor = null; + onCursorReset(error.oldest_cursor); hasMore = true; continue; } @@ -317,6 +322,9 @@ throw new Error(`Strategy29 events request failed with HTTP ${eventsResponse.status}`); } const page = validateStrategy29EventsResponse(eventsBody, 200); + if (requestedCursor === null && (page.has_more || page.events.length > 20)) { + throw new TypeError("Strategy29 latest snapshot must be complete and bounded to 20 events"); + } if (requestedCursor !== null && page.next_cursor < requestedCursor) { throw new TypeError("Strategy29 event cursor moved backwards"); } @@ -1807,7 +1815,7 @@ } function renderEvents() { events.replaceChildren(); - const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence); + const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); for (const event of ordered) { const row = element(document, "div", { role: "remote-event", @@ -1864,7 +1872,7 @@ addEvents(incoming, observedAtMs = null) { assertLive(); for (const event of incoming) eventRecords.set(event.event_id, event); - const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence); + const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); if (observedAtMs !== null) eventsFreshness.textContent = `Events checked ${formatClock(observedAtMs)}`; renderEvents(); @@ -2034,15 +2042,21 @@ } } function sample(nowMs = Date.now()) { - if (disposed) return; + if (disposed || view.document.hidden) return; const context = synchronizeContext(); if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return; + if (context.abortController.signal.aborted) { + const AbortControllerConstructor = view.AbortController ?? AbortController; + context.abortController = new AbortControllerConstructor(); + } + const controller = context.abortController; + const ownsRequest = () => isCurrent(context) && context.abortController === controller; context.nextPollAtMs = nowMs + pollIntervalMs; context.inFlight = true; context.state = "connecting"; context.panel.setConnection("connecting", "Connecting to Strategy 29 gateway"); - return context.client.poll(context.abortController.signal).then((result) => { - if (!isCurrent(context)) return; + return context.client.poll(controller.signal).then((result) => { + if (!ownsRequest()) return; context.lastResult = result; context.lastError = null; context.state = result.state; @@ -2054,7 +2068,7 @@ if (!presentation) throw new Error(`Strategy 29 remote state is invalid: ${result.state}`); context.panel.setConnection(...presentation); }).catch((error) => { - if (!isCurrent(context) || error?.name === "AbortError") return; + if (!ownsRequest() || error?.name === "AbortError") return; context.lastError = error.message; if (error instanceof Strategy29GatewayTransportError) { context.state = "disconnected"; @@ -2066,7 +2080,7 @@ } view.console.warn("[Strategy29 remote]", error.message); }).finally(() => { - context.inFlight = false; + if (ownsRequest()) context.inFlight = false; }); } function restart() { @@ -2096,7 +2110,10 @@ return Object.freeze({ sample, pause() { - stopActive("Strategy 29 remote summary paused"); + if (!active) return; + active.abortController.abort(abortError(view, "Strategy 29 remote summary paused")); + active.inFlight = false; + active.nextPollAtMs = 0; }, restart, dispose() { diff --git a/src/binance-strategy29-bollinger/core/remote-summary-client.js b/src/binance-strategy29-bollinger/core/remote-summary-client.js index 47d5e61..6a6f2b6 100644 --- a/src/binance-strategy29-bollinger/core/remote-summary-client.js +++ b/src/binance-strategy29-bollinger/core/remote-summary-client.js @@ -64,7 +64,10 @@ function assertConfiguration({ request, authSecret, canonicalSymbol, maxPagesPer function buildEventsUrl(origin, canonicalSymbol, cursor) { const url = new URL('/v1/strategy29/events', origin); url.searchParams.set('symbol', canonicalSymbol); - if (cursor !== null) url.searchParams.set('cursor', String(cursor)); + if (cursor === null) { + url.searchParams.set('mode', 'latest'); + url.searchParams.set('limit', '20'); + } else url.searchParams.set('cursor', String(cursor)); return url.href; } @@ -93,6 +96,8 @@ export function createStrategy29SummaryClient({ async function poll(signal) { const statusResponse = await perform(`${origin}/v1/strategy29/status`, signal); + // Aborted host requests can still resolve; they must not mutate a resumed client. + if (signal.aborted) throw signal.reason; const statusBody = parseJsonResponse(statusResponse, 'Strategy29 status'); if (statusResponse.status === 503) { validateStrategy29GatewayError(statusBody, 503); @@ -113,12 +118,13 @@ export function createStrategy29SummaryClient({ while (pages < maxPagesPerPoll) { const requestedCursor = cursor; const eventsResponse = await perform(buildEventsUrl(origin, canonicalSymbol, cursor), signal); + if (signal.aborted) throw signal.reason; const eventsBody = parseJsonResponse(eventsResponse, 'Strategy29 events'); pages += 1; if (eventsResponse.status === 409) { const error = validateStrategy29GatewayError(eventsBody, 409); - cursor = error.oldest_cursor; - onCursorReset(cursor); + cursor = null; + onCursorReset(error.oldest_cursor); hasMore = true; continue; } @@ -131,6 +137,9 @@ export function createStrategy29SummaryClient({ throw new Error(`Strategy29 events request failed with HTTP ${eventsResponse.status}`); } const page = validateStrategy29EventsResponse(eventsBody, 200); + if (requestedCursor === null && (page.has_more || page.events.length > 20)) { + throw new TypeError('Strategy29 latest snapshot must be complete and bounded to 20 events'); + } if (requestedCursor !== null && page.next_cursor < requestedCursor) { throw new TypeError('Strategy29 event cursor moved backwards'); } diff --git a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js index 327f4b9..694d27e 100644 --- a/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js +++ b/src/binance-strategy29-bollinger/dom/strategy29-summary-panel.js @@ -103,7 +103,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve } function renderEvents() { events.replaceChildren(); - const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence); + const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); for (const event of ordered) { const row = element(document, 'div', { role: 'remote-event', @@ -164,7 +164,7 @@ export function createStrategy29SummaryPanel(document, canonicalSymbol, { maxEve addEvents(incoming, observedAtMs = null) { assertLive(); for (const event of incoming) eventRecords.set(event.event_id, event); - const ordered = [...eventRecords.values()].sort((left, right) => right.detected_at_ms - left.detected_at_ms || right.sequence - left.sequence); + const ordered = [...eventRecords.values()].sort((left, right) => right.sequence - left.sequence); while (ordered.length > maxEvents) eventRecords.delete(ordered.pop().event_id); if (observedAtMs !== null) eventsFreshness.textContent = `Events checked ${formatClock(observedAtMs)}`; renderEvents(); diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index 712acf9..6d2de0c 100644 --- a/src/binance-strategy29-bollinger/index.user.js +++ b/src/binance-strategy29-bollinger/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy29.bollinger // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 0.2.0 +// @version 0.2.1 // @author jackhai9 // @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* diff --git a/src/binance-strategy29-bollinger/remote-summary.js b/src/binance-strategy29-bollinger/remote-summary.js index a0a6477..747f6ff 100644 --- a/src/binance-strategy29-bollinger/remote-summary.js +++ b/src/binance-strategy29-bollinger/remote-summary.js @@ -162,16 +162,22 @@ export function createStrategy29RemoteSummary({ } function sample(nowMs = Date.now()) { - if (disposed) return; + if (disposed || view.document.hidden) return; const context = synchronizeContext(); if (!context || !context.client || context.inFlight || context.failed || nowMs < context.nextPollAtMs) return; + if (context.abortController.signal.aborted) { + const AbortControllerConstructor = view.AbortController ?? AbortController; + context.abortController = new AbortControllerConstructor(); + } + const controller = context.abortController; + const ownsRequest = () => isCurrent(context) && context.abortController === controller; context.nextPollAtMs = nowMs + pollIntervalMs; context.inFlight = true; context.state = 'connecting'; context.panel.setConnection('connecting', 'Connecting to Strategy 29 gateway'); - return context.client.poll(context.abortController.signal) + return context.client.poll(controller.signal) .then(result => { - if (!isCurrent(context)) return; + if (!ownsRequest()) return; context.lastResult = result; context.lastError = null; context.state = result.state; @@ -184,7 +190,7 @@ export function createStrategy29RemoteSummary({ context.panel.setConnection(...presentation); }) .catch(error => { - if (!isCurrent(context) || error?.name === 'AbortError') return; + if (!ownsRequest() || error?.name === 'AbortError') return; context.lastError = error.message; if (error instanceof Strategy29GatewayTransportError) { context.state = 'disconnected'; @@ -196,7 +202,7 @@ export function createStrategy29RemoteSummary({ } view.console.warn('[Strategy29 remote]', error.message); }) - .finally(() => { context.inFlight = false; }); + .finally(() => { if (ownsRequest()) context.inFlight = false; }); } function restart() { @@ -227,7 +233,12 @@ export function createStrategy29RemoteSummary({ return Object.freeze({ sample, - pause() { stopActive('Strategy 29 remote summary paused'); }, + pause() { + if (!active) return; + active.abortController.abort(abortError(view, 'Strategy 29 remote summary paused')); + active.inFlight = false; + active.nextPollAtMs = 0; + }, restart, dispose() { if (disposed) return; diff --git a/test/dom/binance-strategy29-bollinger/runtime.test.js b/test/dom/binance-strategy29-bollinger/runtime.test.js index 1f45d2d..a7de290 100644 --- a/test/dom/binance-strategy29-bollinger/runtime.test.js +++ b/test/dom/binance-strategy29-bollinger/runtime.test.js @@ -6,6 +6,7 @@ import { installStrategy29 } from '../../../src/binance-strategy29-bollinger/run import { STRATEGY29_REMOTE_ENABLED_KEY } from '../../../src/binance-strategy29-bollinger/remote-summary.js'; const gatewayStatus = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-status.json', import.meta.url))); +const gatewayEvents = JSON.parse(await readFile(new URL('../../fixtures/strategy29-gateway-events.json', import.meta.url))); function fixture() { const dom = new JSDOM('', { url: 'https://www.binance.com/en/futures/BTRUSDT' }); @@ -113,7 +114,7 @@ test('hiding the page aborts the remote request and resumes with one shared runt await new Promise(resolve => f.view.setTimeout(resolve, 0)); assert.equal(aborts, 1); assert.equal(f.timers.size, 0); - assert.equal(runtime.diagnostics.remoteSummary.contextPresent, false); + assert.equal(runtime.diagnostics.remoteSummary.contextPresent, true); f.hide(false); assert.equal(f.timers.size, 1); assert.equal(runtime.diagnostics.remoteSummary.inFlight, true); @@ -121,6 +122,54 @@ test('hiding the page aborts the remote request and resumes with one shared runt f.dom.window.close(); }); +test('actual remote client retains rows and cursor across visibility and bootstraps a new route', async () => { + const f = fixture(); + const values = new Map([ + [STRATEGY29_REMOTE_ENABLED_KEY, true], + ['strategy29GatewayAuthSecret', 'synthetic-secret'], + ]); + const urls = []; + const first = { ...gatewayEvents.events[0], symbol: 'BTR/USDT:USDT', sequence: 900 }; + const second = { ...gatewayEvents.events[1], symbol: 'BTR/USDT:USDT', sequence: 901 }; + const runtime = installStrategy29(f.view, { + request: async ({ url }) => { + const query = new URL(url); + let body = gatewayStatus; + if (query.pathname.endsWith('/events')) { + urls.push(query); + const records = query.searchParams.get('symbol') === 'ETH/USDT:USDT' + ? [] : query.searchParams.has('cursor') ? [second] : [first]; + body = { ...gatewayEvents, events: records, next_cursor: urls.length === 1 ? 900 : 901, has_more: false }; + } + return { status: 200, responseText: JSON.stringify(body) }; + }, + getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, + setValue: (key, value) => values.set(key, value), + registerMenuCommand() {}, promptUser() { return null; }, + }); + const settle = () => new Promise(resolve => f.view.setTimeout(resolve, 0)); + await settle(); + const panel = f.view.document.getElementById('jh-strategy29-summary-panel'); + assert.equal(urls[0].searchParams.get('mode'), 'latest'); + assert.equal(panel.querySelectorAll('[data-role=remote-event]').length, 1); + f.hide(true); + assert.equal(runtime.diagnostics.remoteSummary.cursor, 900); + f.hide(false); + await settle(); + assert.equal(f.view.document.getElementById('jh-strategy29-summary-panel'), panel); + assert.equal(urls[1].searchParams.get('cursor'), '900'); + assert.equal(runtime.diagnostics.remoteSummary.cursor, 901); + assert.deepEqual([...panel.querySelectorAll('[data-role=remote-event]')].map(row => row.dataset.eventId), [second.event_id, first.event_id]); + f.view.history.pushState({}, '', '/en/futures/ETHUSDT'); + await settle(); + assert.equal(urls[2].searchParams.get('mode'), 'latest'); + assert.equal(urls[2].searchParams.has('cursor'), false); + assert.equal(panel.isConnected, false); + assert.equal(f.view.document.querySelectorAll('[data-role=remote-event]').length, 0); + runtime.dispose(); + f.dom.window.close(); +}); + for (const legacyFirst of [true, false]) { test(`legacy embedded observer refuses coexistence (legacy first=${legacyFirst})`, () => { const f = fixture(); diff --git a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js index 91a69c6..18cf4d4 100644 --- a/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js +++ b/test/dom/binance-strategy29-bollinger/strategy29-summary-panel.test.js @@ -55,3 +55,16 @@ test('makes server/local spec mismatch visible without rendering it as verified' panel.destroy(); dom.window.close(); }); + +test('retains the latest durable sequences even when detection times arrive out of order', () => { + const dom = new JSDOM(''); + const panel = createStrategy29SummaryPanel(dom.window.document, 'BTC/USDT:USDT', { maxEvents: 2 }); + const first = { ...events.events[0], event_id: 'a'.repeat(64), sequence: 10, detected_at_ms: 3000 }; + const second = { ...events.events[0], event_id: 'b'.repeat(64), sequence: 11, detected_at_ms: 2000 }; + const third = { ...events.events[0], event_id: 'c'.repeat(64), sequence: 12, detected_at_ms: 1000 }; + panel.addEvents([first, second]); + panel.addEvents([third]); + assert.deepEqual([...dom.window.document.querySelectorAll('[data-role=remote-event]')].map(row => row.dataset.eventId), [third.event_id, second.event_id]); + panel.destroy(); + dom.window.close(); +}); diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js index 5431b85..34c1457 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-client.test.js @@ -48,6 +48,66 @@ test('normalizes only an explicit loopback HTTP origin', () => { ]) assert.throws(() => normalizeStrategy29GatewayOrigin(value), /loopback origin/); }); +test('bootstraps recent events once then follows the global high-water cursor', async () => { + const fixture = clientFixture([ + response(status), response({ ...events, next_cursor: 100_000, has_more: false }), + response(status), response({ ...events, events: [], next_cursor: 100_200, has_more: false }), + ]); + await fixture.client.poll(new AbortController().signal); + const firstUrl = new URL(fixture.requests[1].url); + assert.equal(firstUrl.searchParams.get('mode'), 'latest'); + assert.equal(firstUrl.searchParams.get('limit'), '20'); + assert.equal(firstUrl.searchParams.has('cursor'), false); + assert.deepEqual(fixture.received.map(event => event.sequence), [41, 42]); + await fixture.client.poll(new AbortController().signal); + const nextUrl = new URL(fixture.requests[3].url); + assert.equal(nextUrl.searchParams.get('cursor'), '100000'); + assert.equal(nextUrl.searchParams.has('mode'), false); + assert.equal(fixture.client.diagnostics.cursor, 100_200); +}); + +test('a cancelled response cannot advance a resumed client cursor or publish events', async () => { + let completeOld; + const oldResponse = new Promise(resolve => { completeOld = resolve; }); + const fixture = clientFixture([ + response(status), oldResponse, + response(status), response({ ...events, next_cursor: 100_000, has_more: false }), + ]); + const oldController = new AbortController(); + const oldPoll = fixture.client.poll(oldController.signal); + await new Promise(resolve => setImmediate(resolve)); + oldController.abort(new DOMException('hidden', 'AbortError')); + await fixture.client.poll(new AbortController().signal); + completeOld(response({ ...events, next_cursor: 500, has_more: false })); + await assert.rejects(oldPoll, error => error.name === 'AbortError'); + assert.equal(fixture.client.diagnostics.cursor, 100_000); + assert.deepEqual(fixture.received.map(event => event.sequence), [41, 42]); +}); + +test('rejects incomplete or oversized latest snapshots', async () => { + for (const latest of [ + { ...events, has_more: true }, + { ...events, has_more: false, events: Array(21).fill(events.events[0]) }, + ]) { + const fixture = clientFixture([response(status), response(latest)]); + await assert.rejects(fixture.client.poll(new AbortController().signal), /complete and bounded/); + assert.equal(fixture.client.diagnostics.cursor, null); + assert.deepEqual(fixture.received, []); + } +}); + +test('a cancelled status response cannot publish status or start another request', async () => { + let complete; + const fixture = clientFixture([new Promise(resolve => { complete = resolve; })]); + const controller = new AbortController(); + const poll = fixture.client.poll(controller.signal); + controller.abort(new DOMException('retired', 'AbortError')); + complete(response(status)); + await assert.rejects(poll, error => error.name === 'AbortError'); + assert.deepEqual(fixture.snapshots, []); + assert.equal(fixture.requests.length, 1); +}); + test('GM transport sends the secret only in Authorization and owns abort', async () => { let options; let aborted = false; @@ -93,37 +153,48 @@ test('GM transport classifies synchronous initialization failure and synchronous test('polls status then bounded event pages and accepts filtered empty progress', async () => { const first = { ...events, events: [], next_cursor: 20, has_more: true }; const second = { ...events, next_cursor: 42, has_more: false }; - const fixture = clientFixture([response(status), response(first), response(second)]); + const fixture = clientFixture([ + response(status), response({ ...events, events: [], next_cursor: 0, has_more: false }), + response(status), response(first), response(second), + ]); + await fixture.client.poll(new AbortController().signal); const result = await fixture.client.poll(new AbortController().signal); assert.deepEqual(result, { state: 'connected', pages: 2, hasMore: false }); assert.equal(fixture.requests[0].url, 'http://127.0.0.1:8729/v1/strategy29/status'); assert.match(fixture.requests[1].url, /symbol=BTC%2FUSDT%3AUSDT/); assert.doesNotMatch(fixture.requests[1].url, /secret|Authorization/i); - assert.match(fixture.requests[2].url, /cursor=20/); + assert.match(fixture.requests[4].url, /cursor=20/); assert.deepEqual(fixture.received.map((event) => event.sequence), [41, 42]); assert.equal(fixture.client.diagnostics.cursor, 42); }); -test('409 advances to the explicit recovery cursor without clearing local chart state', async () => { +test('409 replaces retained history with a recent snapshot without clearing local chart state', async () => { const fixture = clientFixture([ + response(status), response({ ...events, events: [], next_cursor: 0, has_more: false }), response(status), response({ schema_version: 1, error: 'cursor_expired', oldest_cursor: 40 }, 409), response({ ...events, next_cursor: 42, has_more: false }), ]); + await fixture.client.poll(new AbortController().signal); const result = await fixture.client.poll(new AbortController().signal); assert.deepEqual(result, { state: 'connected', pages: 2, hasMore: false }); assert.deepEqual(fixture.resets, [40]); - assert.match(fixture.requests[2].url, /cursor=40/); + assert.match(fixture.requests[4].url, /mode=latest/); + assert.doesNotMatch(fixture.requests[4].url, /cursor=/); }); test('rejects has_more without cursor progress and does not loop', async () => { const stalled = { ...events, events: [], next_cursor: 0, has_more: true }; - const fixture = clientFixture([response(status), response(stalled)]); + const fixture = clientFixture([ + response(status), response({ ...events, events: [], next_cursor: 0, has_more: false }), + response(status), response(stalled), + ]); + await fixture.client.poll(new AbortController().signal); await assert.rejects( fixture.client.poll(new AbortController().signal), /cursor did not advance/, ); - assert.equal(fixture.requests.length, 2); + assert.equal(fixture.requests.length, 4); }); test('rejects an event for a symbol other than the strict requested identity', async () => { diff --git a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js index b3a22d7..eddc5e5 100644 --- a/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js +++ b/test/unit/binance-strategy29-bollinger/remote-summary-controller.test.js @@ -65,6 +65,34 @@ test('remote summary is opt-in and registers configuration without requesting da assert.equal(f.summary.diagnostics.enabled, false); }); +test('pause preserves the current client and panel and permits one resumed request', async () => { + let completeOld; + const oldResponse = new Promise(resolve => { completeOld = resolve; }); + let polls = 0; + const signals = []; + const f = fixture({ poll: signal => { + signals.push(signal); + polls += 1; + return polls === 1 ? oldResponse : new Promise(() => {}); + } }); + const oldPoll = f.summary.sample(0); + f.summary.pause(); + assert.equal(signals[0].aborted, true); + assert.equal(f.summary.diagnostics.contextPresent, true); + assert.equal(f.panels[0].calls.some(call => call[0] === 'destroy'), false); + f.summary.sample(1); + assert.equal(f.clients.length, 1); + assert.equal(f.panels.length, 1); + assert.equal(signals[1].aborted, false); + completeOld({ state: 'connected', pages: 1, hasMore: false }); + await oldPoll; + assert.equal(f.summary.diagnostics.inFlight, true); + assert.equal(f.summary.diagnostics.state, 'connecting'); + assert.equal(f.summary.sample(10_000), undefined); + assert.equal(polls, 2); + f.summary.dispose(); +}); + test('gateway configuration uses only the injected userscript prompt adapter', () => { const f = fixture({ enabled: false }); f.view.prompt = () => { throw new Error('page prompt must not be called'); }; diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index ad1e12e..ae26e67 100644 --- a/test/unit/binance-strategy29-bollinger/source-regressions.test.js +++ b/test/unit/binance-strategy29-bollinger/source-regressions.test.js @@ -52,7 +52,7 @@ test('Bollinger chart alert failures distinguish snapshot races from contract fa }); test('Strategy29 sandbox metadata is exact and coordinates through unsafeWindow', () => { - assert.equal(readUserscriptVersion(entrySource), '0.2.0'); + assert.equal(readUserscriptVersion(entrySource), '0.2.1'); assert.deepEqual( [...entrySource.matchAll(/^\/\/ @grant\s+(\S+)\s*$/gm)].map(match => match[1]), ['unsafeWindow', 'GM_xmlhttpRequest', 'GM_getValue', 'GM_setValue', 'GM_registerMenuCommand'], diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 394cae7..cf22d67 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -21,7 +21,7 @@ test('Strategy29 has an independent observation-only install identity', async () const metadata = parseUserscriptMetadata(text); assert.equal(contract.name, '【自写】Binance Strategy 29 布林带信号'); assert.equal(contract.namespace, 'binance.strategy29.bollinger'); - assert.equal(contract.version, '0.2.0'); + assert.equal(contract.version, '0.2.1'); assert.equal(contract.runAt, 'document-start'); assert.equal(contract.updateURL, 'https://raw.githubusercontent.com/jackhai9/userscripts/main/scripts/binance-strategy29-bollinger.user.js'); assert.equal(contract.downloadURL, contract.updateURL); From e84faf176d4a2ae4e1d08bfb340bc1f68389d718 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:28:09 +0800 Subject: [PATCH 2/2] fix(strategy29): retire remote summaries on permanent runtime failure --- ...inance-strategy29-bollinger-development.md | 3 +- docs/userscript-validation.md | 3 + package.json | 2 +- scripts/binance-strategy29-bollinger.user.js | 1 + src/binance-strategy29-bollinger/runtime.js | 1 + .../runtime.test.js | 74 +++++++++++++++++++ 6 files changed, 82 insertions(+), 2 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index cbdf996..d2dd734 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -71,7 +71,8 @@ component does not enable the observer, gateway, or notifications. Route changes, page hiding, disabling, and disposal abort the owned request. Hiding retains the current client, cursor, and panel rows; resume uses a fresh request owner and continues increments. Route/settings changes retire the old -context. Responses check their original abort signal before changing client state; +context. A permanent local runtime failure disposes the remote summary and removes +its panel; visibility or pageshow cannot revive it. Responses check their original abort signal before changing client state; completion handlers also check request ownership, so an old request cannot publish or clear the in-flight flag of resumed work. No extra recurring timer is installed: the existing one-second runtime sample applies a five-second remote gate. diff --git a/docs/userscript-validation.md b/docs/userscript-validation.md index 11809c8..3d6367b 100644 --- a/docs/userscript-validation.md +++ b/docs/userscript-validation.md @@ -25,6 +25,9 @@ are owned by `skills/userscript-release/SKILL.md`. Run the full `npm test` suite before a release. A focused check can establish that one layer passed; it does not replace a required build, generated-artifact comparison, or live validation step. +The default suite runs all `test/unit/**/*.test.js` and `test/dom/**/*.test.js` +files. Scripts under `test/manual/` are explicit manual probes and previews; +they are not automatically executed by the test runner. ## Shared Contracts diff --git a/package.json b/package.json index c990bdc..3f036cf 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "check:binance-userscripts": "node --check scripts/binance-orderbook-trade.user.js && node --check scripts/binance-trading-data.user.js && node --check scripts/binance-coinmarketcap-data.user.js && node --check scripts/binance-strategy27-events.user.js && node --check scripts/binance-strategy29-bollinger.user.js", "check:binance-orderbook-trade": "node --check scripts/binance-orderbook-trade.user.js", "check:m3u8-downloader": "node --check scripts/m3u8-downloader.user.js", - "test": "node --test", + "test": "node --test 'test/unit/**/*.test.js' 'test/dom/**/*.test.js'", "test:binance-orderbook-trade": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js", "test:binance-orderbook-ui-toolchain": "node --test test/unit/binance-orderbook-trade/*.test.js test/dom/binance-orderbook-trade/*.test.js test/unit/binance-strategy29-bollinger/*.test.js test/dom/binance-strategy29-bollinger/*.test.js test/unit/binance-*.test.js", "test:binance-strategy27-events": "node --test test/unit/binance-strategy27-events/*.test.js test/dom/binance-strategy27-events/*.test.js test/unit/userscript-metadata-icons.test.js test/unit/userscript-release-contract.test.js", diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 106294c..ee4258e 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -2189,6 +2189,7 @@ function fail(message) { failed = message; pause(); + remoteSummary?.dispose(); showFailure(); } function sample() { diff --git a/src/binance-strategy29-bollinger/runtime.js b/src/binance-strategy29-bollinger/runtime.js index 913504f..89e06c7 100644 --- a/src/binance-strategy29-bollinger/runtime.js +++ b/src/binance-strategy29-bollinger/runtime.js @@ -58,6 +58,7 @@ export function installStrategy29(view, remoteAdapters = null) { function fail(message) { failed = message; pause(); + remoteSummary?.dispose(); showFailure(); } function sample() { diff --git a/test/dom/binance-strategy29-bollinger/runtime.test.js b/test/dom/binance-strategy29-bollinger/runtime.test.js index a7de290..fa2d6e2 100644 --- a/test/dom/binance-strategy29-bollinger/runtime.test.js +++ b/test/dom/binance-strategy29-bollinger/runtime.test.js @@ -170,6 +170,80 @@ test('actual remote client retains rows and cursor across visibility and bootstr f.dom.window.close(); }); +for (const failure of ['embedded conflict', 'interval subscription failure']) { + test(`permanent ${failure} retires the populated remote panel and request`, async () => { + const f = fixture(); + const values = new Map([ + [STRATEGY29_REMOTE_ENABLED_KEY, true], + ['strategy29GatewayAuthSecret', 'synthetic-secret'], + ]); + let requests = 0, aborts = 0; + let releaseLate; + const runtime = installStrategy29(f.view, { + request: ({ url, signal }) => { + requests += 1; + if (requests > 2) return new Promise(resolve => { + releaseLate = resolve; + signal.addEventListener('abort', () => { aborts += 1; }, { once: true }); + }); + const body = url.includes('/status') ? gatewayStatus : { + ...gatewayEvents, + events: [{ ...gatewayEvents.events[0], symbol: 'BTR/USDT:USDT' }], + has_more: false, + }; + return Promise.resolve({ status: 200, responseText: JSON.stringify(body) }); + }, + getValue: (key, fallback) => values.has(key) ? values.get(key) : fallback, + setValue: (key, value) => values.set(key, value), + registerMenuCommand() {}, promptUser() { return null; }, + }); + const settle = () => new Promise(resolve => f.view.setTimeout(resolve, 0)); + try { + await settle(); + const panel = f.view.document.getElementById('jh-strategy29-summary-panel'); + assert.equal(panel.querySelectorAll('[data-role=remote-event]').length, 1); + assert.equal(runtime.diagnostics.remoteSummary.state, 'connected'); + f.hide(true); f.hide(false); + assert.equal(requests, 3); + if (failure === 'embedded conflict') { + f.view.__TM_CLOSE_LONG_DEBUG__ = { bollingerAlertState: {} }; + } else { + const root = f.view.document.createElement('div'); + root.className = 'chart-widget-root'; + root.innerHTML = ''; + root.getClientRects = () => [{ width: 800, height: 600 }]; + root.getBoundingClientRect = () => ({ width: 800, height: 600 }); + f.view.document.body.append(root); + const chart = { + symbol: () => 'BTRUSDT@PRICETYPE=LAST', resolution: () => '1', + hasModel: () => true, dataReady: () => true, + onIntervalChanged() { throw new Error('synthetic interval subscription failure'); }, + onDataLoaded() {}, createShape() {}, exportData() {}, + getAllShapes() {}, getShapeById() {}, removeEntity() {}, + }; + root.querySelector('iframe').contentWindow.tradingViewApi = { activeChart: () => chart }; + } + f.tick(); + await settle(); + assert.match(runtime.diagnostics.runtimeFailure, failure === 'embedded conflict' ? /update Orderbook/ : /synthetic interval subscription failure/); + assert.equal(aborts, 1); + assert.equal(panel.isConnected, false); + assert.equal(runtime.diagnostics.remoteSummary.contextPresent, false); + assert.equal(f.timers.size, 0); + releaseLate({ status: 200, responseText: JSON.stringify(gatewayStatus) }); + f.hide(true); f.hide(false); + f.view.dispatchEvent(new f.view.Event('pageshow')); + await settle(); + assert.equal(requests, 3); + assert.equal(f.view.document.getElementById('jh-strategy29-summary-panel'), null); + assert.equal(f.timers.size, 0); + } finally { + runtime.dispose(); + f.dom.window.close(); + } + }); +} + for (const legacyFirst of [true, false]) { test(`legacy embedded observer refuses coexistence (legacy first=${legacyFirst})`, () => { const f = fixture();