diff --git a/docs/binance-strategy27-events-development.md b/docs/binance-strategy27-events-development.md index d4d53df..6ddf16f 100644 --- a/docs/binance-strategy27-events-development.md +++ b/docs/binance-strategy27-events-development.md @@ -30,11 +30,23 @@ symbol. The macOS operator machine keeps this forward under a `launchd` user agent so the SSH process is restarted after sleep, network changes, or a broken -connection. The browser client treats only `GM_xmlhttpRequest` transport errors -and timeouts as recoverable: it retains the current cursor, displays a -reconnecting status, and retries after two seconds. HTTP responses, gateway -errors, malformed JSON, cursor violations, and rendering contract failures -still stop immediately. +connection. The ordinary browser client treats `GM_xmlhttpRequest` transport +errors/timeouts and protocol-validated HTTP 503 unavailability as recoverable: +it retains the current cursor and displayed history, shows a reconnecting status, +and retries after two seconds. A bootstrap 503 remains in bootstrap; a live +`redis_unavailable` response retries the same live cursor. Stale cursors still +use the existing reset/bootstrap contract. Other HTTP/gateway errors, malformed +JSON, cursor violations, and rendering contract failures stop immediately. + +A terminal ordinary-job failure suspends drawing and polling without deleting +previously verified markers or panel history. Pending candle waits and late +creations/repairs lose presentation ownership; late entities are removed. +Retained history is frozen evidence, not a live connection, and the visible +error remains until explicit recovery or a context change. The existing context +timer continues two-hour retention pruning but does not repair a suspended +layer. Use the `Reconnect Strategy 27 and restore history` userscript menu after +resolving the error to start a new context and restore the gateway snapshot. +Manual clear remains available and does not dismiss a terminal error. ## Rendering Contract @@ -83,8 +95,9 @@ be later than that snapshot's end when an ineligible bucket advances the event to its lifecycle deadline without joining the event. The script stores only its own returned marker IDs and its bounded in-memory -panel records. Route, symbol, interval, epoch, cursor, or sequence -discontinuities abort the request and remove only those transient entities. +panel records. Route, symbol, interval, epoch and explicit cursor resets remove +only those transient entities. Sequence/cursor contract violations stop the +ordinary job with an error while retaining its already verified history. Marker count and age are bounded on the chart; the panel retains at most eight events. @@ -111,7 +124,7 @@ ADR 032 in CorsairQuant owns the server-side rule and transport contract. The browser does not reconstruct candidates from ordinary events or recalculate market evidence. The client, lifecycle, panel, native chart layer and optional-job controller are wired into the entrypoint and tested together. The source and -generated install artifact are version 0.4.2 with identical metadata headers. +generated install artifact are version 0.4.3 with identical metadata headers. The generated artifact passes syntax, release-contract and isolated execution checks, including candidate delivery, paired entities, clear and context stop. Binance operator-page validation remains outstanding. Server/gateway rollout diff --git a/scripts/binance-strategy27-events.user.js b/scripts/binance-strategy27-events.user.js index 2df328a..e3badff 100644 --- a/scripts/binance-strategy27-events.user.js +++ b/scripts/binance-strategy27-events.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @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.4.2 +// @version 0.4.3 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -742,6 +742,7 @@ signal }); } catch (error) { + if (signal.aborted) return; if (!(error instanceof Strategy27GatewayTransportError)) throw error; if (!reconnecting) { reconnecting = true; @@ -750,10 +751,11 @@ await waitForReconnect(reconnectDelayMs, signal); continue; } + if (signal.aborted) return; const payload = parseResponseJson(response, needsBootstrap); if (!needsBootstrap) assertCursorContract(payload, cursor); if (payload.status === "error") { - if (needsBootstrap && response.status === 503) { + if (response.status === 503) { if (!reconnecting) { reconnecting = true; onConnectionStateChange("reconnecting"); @@ -767,6 +769,7 @@ reconnecting = false; onConnectionStateChange("connected"); } + if (signal.aborted) return; await onResponse(payload); if (!needsBootstrap && payload.status === "reset") { cursor = null; @@ -1202,6 +1205,7 @@ const pendingRenders = /* @__PURE__ */ new Map(); let renderGeneration = 0; let reconciliation = null; + let suspended = false; function removeRecord(eventId) { pendingRenders.get(eventId)?.abort(); const record = registry.get(eventId); @@ -1219,7 +1223,7 @@ } function restoreMarker(eventId, record, liveIds) { if (record.restoring) return record.restoring; - const current = () => registry.get(eventId) === record && isChartCurrent(); + const current = () => !suspended && registry.get(eventId) === record && isChartCurrent(); if (!current()) return Promise.resolve(false); if (liveIds.has(record.markerId)) return Promise.resolve(true); record.restoring = (async () => { @@ -1236,11 +1240,12 @@ return record.restoring; } function reconcile() { + if (suspended) return Promise.resolve(); if (reconciliation) return reconciliation; reconciliation = (async () => { let liveIds = readLiveShapeIds(chart); for (const [eventId, record] of [...registry]) { - if (registry.get(eventId) !== record || !isChartCurrent()) continue; + if (suspended || registry.get(eventId) !== record || !isChartCurrent()) continue; if (!record.restoring && liveIds.has(record.markerId)) continue; await restoreMarker(eventId, record, liveIds); liveIds = readLiveShapeIds(chart); @@ -1251,6 +1256,7 @@ return reconciliation; } async function ensureMarker(eventId, annotation, observedAtMs) { + if (suspended) return false; let record = registry.get(eventId); if (record) { record.observedAtMs = observedAtMs; @@ -1286,6 +1292,12 @@ remove: removeRecord, prune: pruneAge, reconcile, + /** Stop new presentation without deleting verified history after a job failure. */ + suspend() { + suspended = true; + renderGeneration += 1; + for (const controller of pendingRenders.values()) controller.abort(); + }, clear() { renderGeneration += 1; for (const controller of pendingRenders.values()) controller.abort(); @@ -2703,19 +2715,13 @@ if (error.name === "AbortError" || active !== context || context.failed) return; context.failed = true; context.controller.abort(); - let failure = error; - try { - context.layer.clear(); - } catch (cleanupError) { - failure = new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); - } - context.panel.clear(); - showStatus(context.target.chartRoot, `Strategy 27 已停止:${failure.message}`, "error"); + context.layer.suspend(); + showStatus(context.target.chartRoot, `Strategy 27 stopped; history retained. Use the reconnect menu to resume: ${error.message}`, "error"); } function reconcileOrdinary(context) { - if (context.failed) return; try { pruneOrdinaryEvents(context); + if (context.failed) return; if (context.reconciliation) return; context.reconciliation = context.layer.reconcile().catch((error) => failOrdinary(context, error)).finally(() => { context.reconciliation = null; @@ -2842,7 +2848,7 @@ authSecret, canonicalSymbol, onConnectionStateChange: (state) => { - if (active !== context) return; + if (active !== context || context.failed) return; if (state === "reconnecting") { showStatus(context.target.chartRoot, "Strategy 27 网关连接中断,正在重连", "inactive"); } else { @@ -2938,8 +2944,9 @@ active?.compound.clear(); active?.layer.clear(); active?.panel.clear(); - hideStatus(); + if (!active?.failed) hideStatus(); }); + GM_registerMenuCommand("Reconnect Strategy 27 and restore history", restart); const removeRouteListener = installSpaRouteChangeListener(page, restart); const contextTimer = page.setInterval(synchronizeContext, CONTEXT_CHECK_INTERVAL_MS); page.addEventListener("beforeunload", () => { diff --git a/src/binance-strategy27-events/core/live-event-client.js b/src/binance-strategy27-events/core/live-event-client.js index 92a07f2..dba630d 100644 --- a/src/binance-strategy27-events/core/live-event-client.js +++ b/src/binance-strategy27-events/core/live-event-client.js @@ -143,6 +143,7 @@ export function createLiveEventClient({ signal, }); } catch (error) { + if (signal.aborted) return; if (!(error instanceof Strategy27GatewayTransportError)) throw error; if (!reconnecting) { reconnecting = true; @@ -151,10 +152,12 @@ export function createLiveEventClient({ await waitForReconnect(reconnectDelayMs, signal); continue; } + if (signal.aborted) return; const payload = parseResponseJson(response, needsBootstrap); if (!needsBootstrap) assertCursorContract(payload, cursor); if (payload.status === 'error') { - if (needsBootstrap && response.status === 503) { + // Validated unavailability does not invalidate the retained stream cursor. + if (response.status === 503) { if (!reconnecting) { reconnecting = true; onConnectionStateChange('reconnecting'); @@ -168,6 +171,7 @@ export function createLiveEventClient({ reconnecting = false; onConnectionStateChange('connected'); } + if (signal.aborted) return; await onResponse(payload); if (!needsBootstrap && payload.status === 'reset') { cursor = null; diff --git a/src/binance-strategy27-events/dom/tradingview-event-layer.js b/src/binance-strategy27-events/dom/tradingview-event-layer.js index 2278e54..7879a00 100644 --- a/src/binance-strategy27-events/dom/tradingview-event-layer.js +++ b/src/binance-strategy27-events/dom/tradingview-event-layer.js @@ -268,6 +268,7 @@ export function createTradingViewEventLayer(target, { const pendingRenders = new Map(); let renderGeneration = 0; let reconciliation = null; + let suspended = false; function removeRecord(eventId) { pendingRenders.get(eventId)?.abort(); @@ -289,7 +290,7 @@ export function createTradingViewEventLayer(target, { function restoreMarker(eventId, record, liveIds) { if (record.restoring) return record.restoring; - const current = () => registry.get(eventId) === record && isChartCurrent(); + const current = () => !suspended && registry.get(eventId) === record && isChartCurrent(); if (!current()) return Promise.resolve(false); if (liveIds.has(record.markerId)) return Promise.resolve(true); record.restoring = (async () => { @@ -305,11 +306,12 @@ export function createTradingViewEventLayer(target, { } function reconcile() { + if (suspended) return Promise.resolve(); if (reconciliation) return reconciliation; reconciliation = (async () => { let liveIds = readLiveShapeIds(chart); for (const [eventId, record] of [...registry]) { - if (registry.get(eventId) !== record || !isChartCurrent()) continue; + if (suspended || registry.get(eventId) !== record || !isChartCurrent()) continue; if (!record.restoring && liveIds.has(record.markerId)) continue; await restoreMarker(eventId, record, liveIds); // A native create yields; refresh before examining another record. @@ -320,6 +322,7 @@ export function createTradingViewEventLayer(target, { } async function ensureMarker(eventId, annotation, observedAtMs) { + if (suspended) return false; let record = registry.get(eventId); if (record) { record.observedAtMs = observedAtMs; @@ -356,6 +359,12 @@ export function createTradingViewEventLayer(target, { remove: removeRecord, prune: pruneAge, reconcile, + /** Stop new presentation without deleting verified history after a job failure. */ + suspend() { + suspended = true; + renderGeneration += 1; + for (const controller of pendingRenders.values()) controller.abort(); + }, clear() { renderGeneration += 1; for (const controller of pendingRenders.values()) controller.abort(); diff --git a/src/binance-strategy27-events/index.user.js b/src/binance-strategy27-events/index.user.js index 01f9a6a..6d85828 100644 --- a/src/binance-strategy27-events/index.user.js +++ b/src/binance-strategy27-events/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.strategy27.events // @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.4.2 +// @version 0.4.3 // @author jackhai9 // @description 在 Binance 一秒图表标注 VPS Strategy 27 的实时订单流候选观察 // @match https://www.binance.com/*/futures/* @@ -98,20 +98,14 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; if (error.name === 'AbortError' || active !== context || context.failed) return; context.failed = true; context.controller.abort(); - let failure = error; - try { - context.layer.clear(); - } catch (cleanupError) { - failure = new AggregateError([error, cleanupError], `${error.message}; ${cleanupError.message}`); - } - context.panel.clear(); - showStatus(context.target.chartRoot, `Strategy 27 已停止:${failure.message}`, 'error'); + context.layer.suspend(); + showStatus(context.target.chartRoot, `Strategy 27 stopped; history retained. Use the reconnect menu to resume: ${error.message}`, 'error'); } function reconcileOrdinary(context) { - if (context.failed) return; try { pruneOrdinaryEvents(context); + if (context.failed) return; if (context.reconciliation) return; context.reconciliation = context.layer.reconcile() .catch((error) => failOrdinary(context, error)) @@ -237,7 +231,7 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; authSecret, canonicalSymbol, onConnectionStateChange: (state) => { - if (active !== context) return; + if (active !== context || context.failed) return; if (state === 'reconnecting') { showStatus(context.target.chartRoot, 'Strategy 27 网关连接中断,正在重连', 'inactive'); } else { @@ -345,8 +339,9 @@ import { installSpaRouteChangeListener } from '../shared/spa-route-change.js'; active?.compound.clear(); active?.layer.clear(); active?.panel.clear(); - hideStatus(); + if (!active?.failed) hideStatus(); }); + GM_registerMenuCommand('Reconnect Strategy 27 and restore history', restart); const removeRouteListener = installSpaRouteChangeListener(page, restart); const contextTimer = page.setInterval(synchronizeContext, CONTEXT_CHECK_INTERVAL_MS); diff --git a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js index 08efb29..0d95cfe 100644 --- a/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js +++ b/test/dom/binance-strategy27-events/strategy27-entrypoint.test.js @@ -96,12 +96,13 @@ async function harness(t, { generated = false, beforeCreate } = {}) { await until(() => pending('compound').length === 1); } return { - page, shapes, requests, pending, respond, candidate, timers, + page, chart, shapes, requests, pending, respond, candidate, timers, reset: () => respond('compound', { schema_version: 1, status: 'bootstrap', projection_kind: 'compound_candidates', requested_cursor: null, next_cursor: '1-0', runtime_epoch: 'a'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }), ordinaryBootstrap: () => respond('ordinary', { schema_version: 1, status: 'bootstrap', projection_kind: 'strategy27_events', requested_cursor: null, next_cursor: '1-0', runtime_epoch: 'a'.repeat(32), last_sequence: 1, bootstrap_observed_at_ms: 7000, records: [] }), rows: () => page.document.querySelectorAll('[data-role="compound-row"]').length, tick: () => timers.get(1)(), clear: () => menus.get('清除 Strategy 27 图表标注')(), + restart: () => menus.get('Reconnect Strategy 27 and restore history')(), setNow: (value) => { now = value; }, setResolution: (value) => { resolution = value; }, }; @@ -382,3 +383,68 @@ test('timer expiry cancels an ordinary first creation that is still awaiting Tra assert.deepEqual([...h.shapes.keys()], ['user-owned']); assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); }); + +for (const generated of [false, true]) { + test(`${generated ? 'generated' : 'source'} live 503 retains ordinary history and resumes at the same cursor`, async (t) => { + const h = await harness(t, { generated }); + await h.ordinaryBootstrap(); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [ordinaryMessage()] }); + await until(() => h.pending('ordinary').length === 1); + const ids = [...h.shapes.keys()]; + assert.equal(ids.length, 2); + t.mock.timers.enable({ apis: ['setTimeout'] }); + await h.respond('ordinary', { schema_version: 1, status: 'error', error_code: 'redis_unavailable' }, 503); + await until(() => h.page.document.getElementById('jh-strategy27-event-status') !== null); + assert.equal(h.page.document.getElementById('jh-strategy27-event-status').dataset.state, 'inactive'); + h.tick(); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + t.mock.timers.tick(1999); + assert.equal(h.pending('ordinary').length, 0); + t.mock.timers.tick(1); + await until(() => h.pending('ordinary').length === 1); + assert.equal(new URL(h.pending('ordinary')[0].options.url).searchParams.get('cursor'), '2-0'); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '2-0', next_cursor: '3-0', messages: [{ ...ordinaryMessage(), sequence: 3 }] }); + await until(() => h.pending('ordinary').length === 1); + assert.deepEqual([...h.shapes.keys()], ids); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + assert.equal(h.page.document.getElementById('jh-strategy27-event-status'), null); + }); + + test(`${generated ? 'generated' : 'source'} fatal repair preserves surviving history until expiry or explicit restart`, async (t) => { + const h = await harness(t, { generated }); + await h.ordinaryBootstrap(); + const first = ordinaryMessage(); + const second = { ...ordinaryMessage(), event_id: 'c'.repeat(64), sequence: 3 }; + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '3-0', messages: [first, second] }); + await until(() => h.pending('ordinary').length === 1); + assert.equal(h.shapes.size, 3); + const ids = [...h.shapes.keys()].filter((id) => id !== 'user-owned'); + h.shapes.delete(ids[0]); + const create = h.chart.createShape; + h.chart.createShape = async (point, options) => create({ ...point, time: point.time - 1 }, options); + h.tick(); + await until(() => h.page.document.getElementById('jh-strategy27-event-status')?.dataset.state === 'error'); + assert.match(h.page.document.getElementById('jh-strategy27-event-status').textContent, /time alignment failed/); + assert.deepEqual([...h.shapes.keys()], ['user-owned', ids[1]]); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 2); + assert.equal(h.pending('ordinary').length, 0); + h.tick(); + await new Promise(setImmediate); + assert.deepEqual([...h.shapes.keys()], ['user-owned', ids[1]]); + h.setNow(7207001); + h.tick(); + assert.deepEqual([...h.shapes.keys()], ['user-owned']); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 0); + h.chart.createShape = create; + h.setNow(7000); + h.restart(); + assert.equal(h.pending('ordinary').length, 1); + assert.equal(new URL(h.pending('ordinary')[0].options.url).pathname, '/v1/strategy27/events/bootstrap'); + await h.ordinaryBootstrap(); + await h.respond('ordinary', { schema_version: 1, status: 'ok', requested_cursor: '1-0', next_cursor: '2-0', messages: [first] }); + await until(() => h.pending('ordinary').length === 1); + assert.equal(h.shapes.size, 2); + assert.equal(h.page.document.querySelectorAll('[data-role="event-row"]').length, 1); + }); +} diff --git a/test/dom/binance-strategy27-events/tradingview-event-layer.test.js b/test/dom/binance-strategy27-events/tradingview-event-layer.test.js index 45ddc40..2880164 100644 --- a/test/dom/binance-strategy27-events/tradingview-event-layer.test.js +++ b/test/dom/binance-strategy27-events/tradingview-event-layer.test.js @@ -377,7 +377,7 @@ test('reconciliation restores missing ordinary markers without an event and neve }); test('ordinary timer and gateway repair share a single creation and cancel stale results', async () => { - for (const action of ['retain', 'clear', 'remove', 'expire', 'interval', 'symbol']) { + for (const action of ['retain', 'clear', 'remove', 'expire', 'interval', 'symbol', 'suspend']) { const f = createChartDom(); const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 2, maxAgeMs: 60000 }); await layer.renderOpened('a', annotation(), 10000); @@ -397,6 +397,7 @@ test('ordinary timer and gateway repair share a single creation and cancel stale const update = layer.renderUpdated('a', annotation(), 11000); const anotherTick = layer.reconcile(); if (action === 'clear') layer.clear(); + if (action === 'suspend') layer.suspend(); if (action === 'remove') layer.remove('a'); if (action === 'expire') layer.prune(7200000); if (action === 'interval') f.chart.resolution = () => '1'; @@ -412,3 +413,26 @@ test('ordinary timer and gateway repair share a single creation and cancel stale assert.equal(f.shapes.size, 0); } }); + +test('suspension retains existing markers, cancels late first creation and still permits expiry', async () => { + const f = createChartDom(); + const layer = createTradingViewEventLayer({ chart: f.chart }, { maxEvents: 3, maxAgeMs: 60000 }); + await layer.renderOpened('a', annotation(), 10000); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const create = f.chart.createShape; + f.chart.createShape = async (...args) => { entered.resolve(); await release.promise; return create(...args); }; + const pending = layer.renderOpened('b', annotation(), 11000); + await entered.promise; + layer.suspend(); + release.resolve(); + assert.equal(await pending, false); + assert.deepEqual([...f.shapes.keys()], ['shape-1']); + assert.equal(layer.size, 1); + assert.equal(await layer.renderOpened('c', annotation(), 12000), false); + await layer.reconcile(); + assert.deepEqual([...f.shapes.keys()], ['shape-1']); + layer.prune(70001); + assert.equal(layer.size, 0); + assert.equal(f.shapes.size, 0); +}); diff --git a/test/unit/binance-strategy27-events/live-event-client.test.js b/test/unit/binance-strategy27-events/live-event-client.test.js index 97f9269..f26f1e4 100644 --- a/test/unit/binance-strategy27-events/live-event-client.test.js +++ b/test/unit/binance-strategy27-events/live-event-client.test.js @@ -5,6 +5,7 @@ import { createGmJsonRequest, createLiveEventClient, normalizeGatewayBaseUrl, + Strategy27GatewayTransportError, } from '../../../src/binance-strategy27-events/core/live-event-client.js'; const bootstrap = (next = '5-0') => ({ @@ -171,3 +172,71 @@ test('does not retry response contract failures', async () => { await assert.rejects(client.run(new AbortController().signal), /invalid JSON/); assert.equal(requestCount, 1); }); + +test('validated 503 retains the bootstrap phase and the live cursor until recovery', async () => { + const urls = []; + const states = []; + const published = []; + const controller = new AbortController(); + const unavailable = { status: 503, responseText: JSON.stringify({ schema_version: 1, status: 'error', error_code: 'redis_unavailable' }) }; + const responses = [unavailable, { status: 200, responseText: JSON.stringify(bootstrap()) }, unavailable, unavailable, + { status: 200, responseText: JSON.stringify({ schema_version: 1, status: 'ok', requested_cursor: '5-0', next_cursor: '8-0', messages: [] }) }]; + const client = createLiveEventClient({ + request: async ({ url }) => { urls.push(new URL(url)); assert.ok(responses.length > 0); return responses.shift(); }, + gatewayBaseUrl: 'http://127.0.0.1:18765', authSecret: 'synthetic-test-value', canonicalSymbol: 'BTR/USDT:USDT', + reconnectDelayMs: 0, onConnectionStateChange: (state) => states.push(state), + onResponse: async (payload) => { published.push(payload.status); if (published.length === 2) controller.abort(); }, + }); + await client.run(controller.signal); + assert.deepEqual(urls.map((url) => url.pathname.endsWith('/bootstrap')), [true, true, false, false, false]); + assert.deepEqual(urls.map((url) => url.searchParams.get('cursor')), [null, null, '5-0', '5-0', '5-0']); + assert.deepEqual(published, ['bootstrap', 'ok']); + assert.deepEqual(states, ['reconnecting', 'connected', 'reconnecting', 'connected']); +}); + +test('a stopped request cannot publish a late response or connection status', async () => { + const controller = new AbortController(); + const states = []; + const published = []; + const client = createLiveEventClient({ + request: async () => { controller.abort(); return { status: 200, responseText: JSON.stringify(bootstrap()) }; }, + gatewayBaseUrl: 'http://127.0.0.1:18765', authSecret: 'synthetic-test-value', canonicalSymbol: 'BTR/USDT:USDT', + onConnectionStateChange: (state) => states.push(state), onResponse: async (payload) => published.push(payload), + }); + await client.run(controller.signal); + assert.deepEqual(states, []); + assert.deepEqual(published, []); +}); + +for (const lateFailure of ['503', 'transport']) { + test(`aborting before a late ${lateFailure} suppresses reconnecting state`, async () => { + const controller = new AbortController(); + const states = []; + const client = createLiveEventClient({ + request: async () => { + controller.abort(); + if (lateFailure === 'transport') throw new Strategy27GatewayTransportError('synthetic transport failure'); + return { status: 503, responseText: JSON.stringify({ schema_version: 1, status: 'error', error_code: 'redis_unavailable' }) }; + }, + gatewayBaseUrl: 'http://127.0.0.1:18765', authSecret: 'synthetic-test-value', canonicalSymbol: 'BTR/USDT:USDT', + onConnectionStateChange: (state) => states.push(state), onResponse: async () => assert.fail('Stopped response was published'), + }); + await client.run(controller.signal); + assert.deepEqual(states, []); + }); +} + +test('a validated authorization error remains terminal after live polling starts', async () => { + let attempts = 0; + const states = []; + const client = createLiveEventClient({ + request: async () => ++attempts === 1 + ? { status: 200, responseText: JSON.stringify(bootstrap()) } + : { status: 401, responseText: JSON.stringify({ schema_version: 1, status: 'error', error_code: 'unauthorized' }) }, + gatewayBaseUrl: 'http://127.0.0.1:18765', authSecret: 'synthetic-test-value', canonicalSymbol: 'BTR/USDT:USDT', + onConnectionStateChange: (state) => states.push(state), onResponse: async () => {}, + }); + await assert.rejects(client.run(new AbortController().signal), /gateway error: unauthorized/); + assert.equal(attempts, 2); + assert.deepEqual(states, []); +}); diff --git a/test/unit/userscript-release-contract.test.js b/test/unit/userscript-release-contract.test.js index 8784ae5..20d3372 100644 --- a/test/unit/userscript-release-contract.test.js +++ b/test/unit/userscript-release-contract.test.js @@ -63,7 +63,7 @@ test('release contract identifies the generated Strategy 27 annotation artifact' assert.equal(contract.name, '【自写】Binance Strategy 27 事件标注'); assert.equal(contract.namespace, 'binance.strategy27.events'); - assert.equal(contract.version, '0.4.2'); + assert.equal(contract.version, '0.4.3'); assert.equal(contract.runAt, 'document-idle'); assert.equal(contract.updateURL, contract.downloadURL); assert.deepEqual(contract.matches, [