From 5950e8e1eb456a1b7e8c6743c7d1e4df4f2ea39d Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:12:52 +0800 Subject: [PATCH 1/3] fix(strategy29): retain local monitor failure diagnostics --- ...inance-strategy29-bollinger-development.md | 10 ++- scripts/binance-strategy29-bollinger.user.js | 36 ++++++-- .../index.user.js | 2 +- src/binance-strategy29-bollinger/monitor.js | 36 +++++++- .../tradingview-bearish-alerts.test.js | 87 ++++++++++++++++++- .../source-regressions.test.js | 2 +- test/unit/userscript-release-contract.test.js | 2 +- 7 files changed, 161 insertions(+), 14 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index d2dd734..998deba 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.1 with orderbook 2.7.199 or later, or use it alone. +Install Strategy29 0.2.2 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. @@ -35,6 +35,14 @@ BFCache pagehide pause it; visibility/pageshow resumes it. Permanent disposal removes its listeners, aborts an in-flight summary request, and invalidates pending drawing work. Non-trading routes perform no candle exports or gateway requests. +The read-only diagnostics retain one `lastLocalFailure` after local fatal cleanup +or stop. It records the export/reconcile/detect/render stage, thrown value type, +bounded name (64 characters) and message (512 characters), route, interval and +pre-cleanup counts. String rejections supply the message directly; missing string +name/message fields are null, and arbitrary rejected objects are not serialized. +It describes the last fatal event, not necessarily the active context. It does not +store stack traces, candles, requests or gateway credentials, and is never persisted +or sent to the remote service. Recoverable snapshot races leave it unchanged. Independent instances of each bundle share the same controller regardless of load order. Strategy27 does not participate in this protocol. diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index ee4258e..9f7b6cf 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.1 +// @version 0.2.2 // @author jackhai9 // @description Native Bollinger/SMA60 markers with an optional read-only cross-timeframe summary // @match https://www.binance.com/*/futures/* @@ -1473,6 +1473,7 @@ let bearishBollingerAlertTask = null; let bearishBollingerAlertContext = null; let bollingerIntervalSession = null; + let lastLocalFailure = null; const retiredBollingerLayers = /* @__PURE__ */ new Set(); function clearBearishBollingerAlertContext() { if (bearishBollingerAlertContext) { @@ -1552,19 +1553,31 @@ context.cleanupPending = false; } if (context.failed || bearishBollingerAlertTask) return; + let stage = "export"; const task = (async () => { const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; if (bars.length === 0) return; + stage = "reconcile"; const result = await reconcileBearishBollingerAlertWindow({ bars, cachedWindowKey: context.lastProcessedClosedBarsWindowKey, cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, cachedSignals: context.lastProcessedSignals, - detectSignals: detectBollingerSignals, - renderSignals: (signals) => context.layer.render(signals, { - isCurrent: () => isBearishBollingerAlertContextCurrent(context) - }) + detectSignals: (bars2) => { + stage = "detect"; + const signals = detectBollingerSignals(bars2); + stage = "reconcile"; + return signals; + }, + renderSignals: async (signals) => { + stage = "render"; + const rendered = await context.layer.render(signals, { + isCurrent: () => isBearishBollingerAlertContextCurrent(context) + }); + stage = "reconcile"; + return rendered; + } }); if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; @@ -1580,6 +1593,18 @@ warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); return; } + lastLocalFailure = Object.freeze({ + thrownType: error === null ? "null" : typeof error, + name: typeof error?.name === "string" ? error.name.slice(0, 64) : null, + message: typeof error === "string" ? error.slice(0, 512) : typeof error?.message === "string" ? error.message.slice(0, 512) : null, + stage, + routeSymbol: context.routeSymbol, + resolution: context.resolution, + cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, + layerSizeBeforeCleanup: context.layer.size, + sessionRevision: context.intervalSession.revision, + contextIntervalRevision: context.intervalRevision + }); err("布林带形态预警已停止:", error); }).finally(() => { if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; @@ -1598,6 +1623,7 @@ taskPending: bearishBollingerAlertTask !== null, contextPresent: context !== null, failed: context ? context.failed : null, + lastLocalFailure, cleanupPending: context ? context.cleanupPending : null, cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, layerSize: context ? context.layer.size : null, diff --git a/src/binance-strategy29-bollinger/index.user.js b/src/binance-strategy29-bollinger/index.user.js index 6d2de0c..833df2c 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.1 +// @version 0.2.2 // @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/monitor.js b/src/binance-strategy29-bollinger/monitor.js index 9f33b12..1e2b067 100644 --- a/src/binance-strategy29-bollinger/monitor.js +++ b/src/binance-strategy29-bollinger/monitor.js @@ -20,6 +20,7 @@ export function createBollingerMonitor({ let bearishBollingerAlertTask = null; let bearishBollingerAlertContext = null; let bollingerIntervalSession = null; + let lastLocalFailure = null; const retiredBollingerLayers = new Set(); function clearBearishBollingerAlertContext() { @@ -129,19 +130,31 @@ export function createBollingerMonitor({ context.cleanupPending = false; } if (context.failed || bearishBollingerAlertTask) return; + let stage = 'export'; const task = (async () => { const bars = await exportClosedTradingViewBars(context.target, context.intervalSession); if (!bars || !isBearishBollingerAlertContextCurrent(context)) return; if (bars.length === 0) return; + stage = 'reconcile'; const result = await reconcileBearishBollingerAlertWindow({ bars, cachedWindowKey: context.lastProcessedClosedBarsWindowKey, cachedContentSnapshot: context.lastProcessedClosedBarsContentSnapshot, cachedSignals: context.lastProcessedSignals, - detectSignals: detectBollingerSignals, - renderSignals: (signals) => context.layer.render(signals, { - isCurrent: () => isBearishBollingerAlertContextCurrent(context), - }), + detectSignals: (bars) => { + stage = 'detect'; + const signals = detectBollingerSignals(bars); + stage = 'reconcile'; + return signals; + }, + renderSignals: async (signals) => { + stage = 'render'; + const rendered = await context.layer.render(signals, { + isCurrent: () => isBearishBollingerAlertContextCurrent(context), + }); + stage = 'reconcile'; + return rendered; + }, }); if (result.rendered && isBearishBollingerAlertContextCurrent(context)) { context.lastProcessedClosedBarsWindowKey = result.closedBarsWindowKey; @@ -164,6 +177,20 @@ export function createBollingerMonitor({ warn('布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:', error); return; } + /** Preserve host rejection types without serializing arbitrary objects or stacks. */ + lastLocalFailure = Object.freeze({ + thrownType: error === null ? 'null' : typeof error, + name: typeof error?.name === 'string' ? error.name.slice(0, 64) : null, + message: typeof error === 'string' ? error.slice(0, 512) + : typeof error?.message === 'string' ? error.message.slice(0, 512) : null, + stage, + routeSymbol: context.routeSymbol, + resolution: context.resolution, + cachedSignalCount: context.lastProcessedSignals === null ? null : context.lastProcessedSignals.length, + layerSizeBeforeCleanup: context.layer.size, + sessionRevision: context.intervalSession.revision, + contextIntervalRevision: context.intervalRevision, + }); err('布林带形态预警已停止:', error); }).finally(() => { if (bearishBollingerAlertTask === task) bearishBollingerAlertTask = null; @@ -186,6 +213,7 @@ export function createBollingerMonitor({ taskPending: bearishBollingerAlertTask !== null, contextPresent: context !== null, failed: context ? context.failed : null, + lastLocalFailure, cleanupPending: context ? context.cleanupPending : null, cachedSignalCount: context?.lastProcessedSignals === null || !context ? null : context.lastProcessedSignals.length, diff --git a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js index cb6cdd7..eff399f 100644 --- a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js +++ b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js @@ -125,6 +125,7 @@ function createMonitorHarness(fixture, dependencyOverrides = {}) { let bearishBollingerAlertTask = null; let bearishBollingerAlertContext = null; let bollingerIntervalSession = null; + let lastLocalFailure = null; const retiredBollingerLayers = new Set(); const ladderTask = null, continuousLadderTask = null, singleOrderTask = null; const cancelCurrentSymbolOpenOrdersTask = null, chartOrdersRecoveryTask = null; @@ -666,7 +667,7 @@ test('on-demand diagnostics distinguish active, awaiting-data and torn-down stat fixture.chart.getAllShapes = () => { throw new Error('Diagnostics must not audit drawings'); }; fixture.chart.exportData = () => { throw new Error('Diagnostics must not export data'); }; assert.deepEqual(harness.monitor.diagnostics, { - taskPending: false, contextPresent: true, failed: false, + taskPending: false, contextPresent: true, failed: false, lastLocalFailure: null, cleanupPending: false, cachedSignalCount: 1, layerSize: 1, retiredCount: 0, markerSaveStats: { busy: true, mutations: 0, draining: 0, saveRequests: 0, serializations: 0, callbackCount: 0, failureCount: 0, pendingCallbacks: 0 }, @@ -686,6 +687,90 @@ test('on-demand diagnostics distinguish active, awaiting-data and torn-down stat harness.monitor.stop(); }); +for (const stage of ['export', 'render']) { + test(`retains the last local ${stage} failure after clearing the layer and stopping`, async () => { + const fixture = createChartDom(); + const harness = createMonitorHarness(fixture, { + detectBollingerSignals: bars => bars.map(bar => ({ + id: `${bar.time}:warning`, direction: 'bearish', type: 'warning', + time: bar.time, markerPrice: 13, + })), + }); + fixture.chart.exportData = async () => exportResult(Array.from({ length: 6 }, (_, index) => ({ + 0: (index + 1) * 60, 1: 10, 2: 12, 3: 9, 4: 11, + }))); + await harness.tick(); + assert.equal(harness.monitor.diagnostics.cachedSignalCount, 6); + assert.equal(harness.monitor.diagnostics.layerSize, 6); + if (stage === 'export') fixture.chart.exportData = async () => { throw new Error('synthetic export failure'); }; + else fixture.chart.getShapeById = () => undefined; + const message = stage === 'export' ? 'synthetic export failure' : 'TradingView Bollinger alert marker point is invalid'; + await assert.rejects(harness.tick(), { message }); + await harness.tick(); + assert.equal(harness.monitor.diagnostics.failed, true); + assert.equal(harness.monitor.diagnostics.cleanupPending, false); + assert.equal(harness.monitor.diagnostics.layerSize, 0); + const expected = { + thrownType: 'object', name: 'Error', message, stage, routeSymbol: 'BTRUSDT', resolution: '1', + cachedSignalCount: 6, layerSizeBeforeCleanup: 6, + sessionRevision: 0, contextIntervalRevision: 0, + }; + assert.deepEqual(harness.monitor.diagnostics.lastLocalFailure, expected); + assert.equal(Object.isFrozen(harness.monitor.diagnostics.lastLocalFailure), true); + harness.monitor.stop(); + assert.deepEqual(harness.monitor.diagnostics.lastLocalFailure, expected); + fixture.dom.window.close(); + }); +} + +for (const value of ['x'.repeat(600), null, { code: 7 }]) { + test(`records ${value === null ? 'null' : typeof value} host rejection without a diagnostic rejection`, async () => { + const fixture = createChartDom(); + const harness = createMonitorHarness(fixture); + fixture.chart.exportData = async () => { throw value; }; + await assert.rejects(harness.tick(), reason => reason === value); + await harness.tick(); + assert.equal(harness.monitor.diagnostics.failed, true); + assert.equal(harness.monitor.diagnostics.cleanupPending, false); + assert.deepEqual(harness.monitor.diagnostics.lastLocalFailure, { + thrownType: value === null ? 'null' : typeof value, + name: null, message: typeof value === 'string' ? 'x'.repeat(512) : null, + stage: 'export', routeSymbol: 'BTRUSDT', resolution: '1', + cachedSignalCount: null, layerSizeBeforeCleanup: 0, + sessionRevision: 0, contextIntervalRevision: 0, + }); + assert.equal(harness.errors.length, 1); + assert.equal(harness.errors[0][1], value); + harness.monitor.stop(); + fixture.dom.window.close(); + }); +} + +for (const stage of ['detect', 'reconcile']) { + test(`identifies ${stage} failures before rendering`, async () => { + const fixture = createChartDom(); + const failure = new Error('synthetic detector failure'); + failure.name = 'N'.repeat(80); + const harness = createMonitorHarness(fixture, { + detectBollingerSignals: () => { + if (stage === 'detect') throw failure; + return undefined; + }, + }); + fixture.chart.exportData = async () => exportResult([{ 0: 60, 1: 10, 2: 12, 3: 9, 4: 11 }]); + const message = stage === 'detect' ? failure.message : 'Bollinger signal cache is invalid'; + await assert.rejects(harness.tick(), { message }); + await harness.tick(); + assert.equal(harness.monitor.diagnostics.lastLocalFailure.stage, stage); + assert.equal(harness.monitor.diagnostics.lastLocalFailure.name, stage === 'detect' ? 'N'.repeat(64) : 'Error'); + assert.equal(harness.monitor.diagnostics.lastLocalFailure.message, message); + assert.equal(harness.monitor.diagnostics.failed, true); + assert.equal(fixture.createdOptions.length, 0); + harness.monitor.stop(); + fixture.dom.window.close(); + }); +} + test('requires the current symbol and complete bearish-alert chart API', () => { const { dom, diff --git a/test/unit/binance-strategy29-bollinger/source-regressions.test.js b/test/unit/binance-strategy29-bollinger/source-regressions.test.js index ae26e67..1597196 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.1'); + assert.equal(readUserscriptVersion(entrySource), '0.2.2'); 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 cf22d67..8784ae5 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.1'); + assert.equal(contract.version, '0.2.2'); 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 c4ccf5038ac74cc199dcd32c340a291fc3efb733 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:20:36 +0800 Subject: [PATCH 2/3] fix(strategy29): isolate unreadable host rejection fields --- ...inance-strategy29-bollinger-development.md | 3 ++ scripts/binance-strategy29-bollinger.user.js | 14 ++++++-- src/binance-strategy29-bollinger/monitor.js | 16 +++++++-- .../tradingview-bearish-alerts.test.js | 34 +++++++++++++++++-- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index 998deba..b8a7931 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -40,6 +40,9 @@ or stop. It records the export/reconcile/detect/render stage, thrown value type, bounded name (64 characters) and message (512 characters), route, interval and pre-cleanup counts. String rejections supply the message directly; missing string name/message fields are null, and arbitrary rejected objects are not serialized. +Each field is read once. A throwing host accessor leaves that field null and adds +its name to `unreadableFields`; this diagnostic boundary cannot replace the original +monitor failure with a property-read failure. It describes the last fatal event, not necessarily the active context. It does not store stack traces, candles, requests or gateway credentials, and is never persisted or sent to the remote service. Recoverable snapshot races leave it unchanged. diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index 9f7b6cf..ec0fb99 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -1593,10 +1593,20 @@ warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); return; } + const details = { name: null, message: null }; + const unreadableFields = []; + for (const [key, limit] of [["name", 64], ["message", 512]]) { + try { + const value = key === "message" && typeof error === "string" ? error : error?.[key]; + details[key] = typeof value === "string" ? value.slice(0, limit) : null; + } catch { + unreadableFields.push(key); + } + } lastLocalFailure = Object.freeze({ thrownType: error === null ? "null" : typeof error, - name: typeof error?.name === "string" ? error.name.slice(0, 64) : null, - message: typeof error === "string" ? error.slice(0, 512) : typeof error?.message === "string" ? error.message.slice(0, 512) : null, + ...details, + unreadableFields: Object.freeze(unreadableFields), stage, routeSymbol: context.routeSymbol, resolution: context.resolution, diff --git a/src/binance-strategy29-bollinger/monitor.js b/src/binance-strategy29-bollinger/monitor.js index 1e2b067..13dd9d9 100644 --- a/src/binance-strategy29-bollinger/monitor.js +++ b/src/binance-strategy29-bollinger/monitor.js @@ -178,11 +178,21 @@ export function createBollingerMonitor({ return; } /** Preserve host rejection types without serializing arbitrary objects or stacks. */ + const details = { name: null, message: null }; + const unreadableFields = []; + for (const [key, limit] of [['name', 64], ['message', 512]]) { + try { + const value = key === 'message' && typeof error === 'string' ? error : error?.[key]; + details[key] = typeof value === 'string' ? value.slice(0, limit) : null; + } catch { + // Host rejection accessors may throw; preserve that fact without replacing the original failure. + unreadableFields.push(key); + } + } lastLocalFailure = Object.freeze({ thrownType: error === null ? 'null' : typeof error, - name: typeof error?.name === 'string' ? error.name.slice(0, 64) : null, - message: typeof error === 'string' ? error.slice(0, 512) - : typeof error?.message === 'string' ? error.message.slice(0, 512) : null, + ...details, + unreadableFields: Object.freeze(unreadableFields), stage, routeSymbol: context.routeSymbol, resolution: context.resolution, diff --git a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js index eff399f..f1e9c9c 100644 --- a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js +++ b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js @@ -711,7 +711,7 @@ for (const stage of ['export', 'render']) { assert.equal(harness.monitor.diagnostics.cleanupPending, false); assert.equal(harness.monitor.diagnostics.layerSize, 0); const expected = { - thrownType: 'object', name: 'Error', message, stage, routeSymbol: 'BTRUSDT', resolution: '1', + thrownType: 'object', name: 'Error', message, unreadableFields: [], stage, routeSymbol: 'BTRUSDT', resolution: '1', cachedSignalCount: 6, layerSizeBeforeCleanup: 6, sessionRevision: 0, contextIntervalRevision: 0, }; @@ -735,7 +735,7 @@ for (const value of ['x'.repeat(600), null, { code: 7 }]) { assert.deepEqual(harness.monitor.diagnostics.lastLocalFailure, { thrownType: value === null ? 'null' : typeof value, name: null, message: typeof value === 'string' ? 'x'.repeat(512) : null, - stage: 'export', routeSymbol: 'BTRUSDT', resolution: '1', + unreadableFields: [], stage: 'export', routeSymbol: 'BTRUSDT', resolution: '1', cachedSignalCount: null, layerSizeBeforeCleanup: 0, sessionRevision: 0, contextIntervalRevision: 0, }); @@ -746,6 +746,36 @@ for (const value of ['x'.repeat(600), null, { code: 7 }]) { }); } +for (const throwing of [true, false]) { + test(`reads host rejection accessors once and records unreadable fields (throwing=${throwing})`, async () => { + const fixture = createChartDom(); + const harness = createMonitorHarness(fixture); + const reads = { name: 0, message: 0 }; + const rejection = {}; + for (const key of ['name', 'message']) { + Object.defineProperty(rejection, key, { get() { + reads[key] += 1; + if (throwing) throw new Error('synthetic inaccessible diagnostic field'); + return reads[key] === 1 ? key : null; + } }); + } + fixture.chart.exportData = async () => { throw rejection; }; + await assert.rejects(harness.tick(), reason => reason === rejection); + await harness.tick(); + assert.deepEqual(reads, { name: 1, message: 1 }); + const recorded = harness.monitor.diagnostics.lastLocalFailure; + assert.deepEqual(recorded.unreadableFields, throwing ? ['name', 'message'] : []); + assert.equal(recorded.name, throwing ? null : 'name'); + assert.equal(recorded.message, throwing ? null : 'message'); + assert.equal(recorded.stage, 'export'); + assert.equal(harness.monitor.diagnostics.failed, true); + assert.equal(harness.monitor.diagnostics.cleanupPending, false); + assert.equal(harness.errors[0][1], rejection); + harness.monitor.stop(); + fixture.dom.window.close(); + }); +} + for (const stage of ['detect', 'reconcile']) { test(`identifies ${stage} failures before rendering`, async () => { const fixture = createChartDom(); From 21adf7444aa21572b79020bddf410d2813a50727 Mon Sep 17 00:00:00 2001 From: LiZhenhai-MBP14 <5935568+jackhai9@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:31:44 +0800 Subject: [PATCH 3/3] fix(strategy29): stop unclassifiable host rejections --- ...inance-strategy29-bollinger-development.md | 3 + scripts/binance-strategy29-bollinger.user.js | 12 +++- src/binance-strategy29-bollinger/monitor.js | 13 ++++- .../tradingview-bearish-alerts.test.js | 58 ++++++++++++++++++- 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/docs/binance-strategy29-bollinger-development.md b/docs/binance-strategy29-bollinger-development.md index b8a7931..7c43b5d 100644 --- a/docs/binance-strategy29-bollinger-development.md +++ b/docs/binance-strategy29-bollinger-development.md @@ -43,6 +43,9 @@ name/message fields are null, and arbitrary rejected objects are not serialized. Each field is read once. A throwing host accessor leaves that field null and adds its name to `unreadableFields`; this diagnostic boundary cannot replace the original monitor failure with a property-read failure. +If a host Proxy throws during rejection classification, the monitor stops that +context and records `classificationFailed: true`; normal fatal errors record false. +The frozen detector/core source and its direct invalid-context assertion remain unchanged. It describes the last fatal event, not necessarily the active context. It does not store stack traces, candles, requests or gateway credentials, and is never persisted or sent to the remote service. Recoverable snapshot races leave it unchanged. diff --git a/scripts/binance-strategy29-bollinger.user.js b/scripts/binance-strategy29-bollinger.user.js index ec0fb99..e6619e9 100644 --- a/scripts/binance-strategy29-bollinger.user.js +++ b/scripts/binance-strategy29-bollinger.user.js @@ -1588,7 +1588,16 @@ bearishBollingerAlertTask = task; task.catch((error) => { if (bearishBollingerAlertContext !== context || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision) return; - const failureKind = applyBollingerAlertTaskFailure(context, error); + let failureKind; + let classificationFailed = false; + try { + failureKind = applyBollingerAlertTaskFailure(context, error); + } catch { + classificationFailed = true; + context.failed = true; + context.cleanupPending = true; + failureKind = "fatal"; + } if (failureKind === "retry") { warn("布林带形态预警本轮快照不一致,保留现有标记并等待下一次采样:", error); return; @@ -1605,6 +1614,7 @@ } lastLocalFailure = Object.freeze({ thrownType: error === null ? "null" : typeof error, + classificationFailed, ...details, unreadableFields: Object.freeze(unreadableFields), stage, diff --git a/src/binance-strategy29-bollinger/monitor.js b/src/binance-strategy29-bollinger/monitor.js index 13dd9d9..efc7673 100644 --- a/src/binance-strategy29-bollinger/monitor.js +++ b/src/binance-strategy29-bollinger/monitor.js @@ -169,7 +169,17 @@ export function createBollingerMonitor({ || context.intervalSession !== bollingerIntervalSession?.session || context.intervalRevision !== context.intervalSession.revision ) return; - const failureKind = applyBollingerAlertTaskFailure(context, error); + let failureKind; + let classificationFailed = false; + try { + failureKind = applyBollingerAlertTaskFailure(context, error); + } catch { + // A revoked host Proxy can throw during instanceof; an unclassifiable rejection must stop this context. + classificationFailed = true; + context.failed = true; + context.cleanupPending = true; + failureKind = 'fatal'; + } if (failureKind === 'retry') { // TradingView can expose one feed-update race through exportData(). Keep the // already-rendered layer and retry the next poll instead of turning a transient @@ -191,6 +201,7 @@ export function createBollingerMonitor({ } lastLocalFailure = Object.freeze({ thrownType: error === null ? 'null' : typeof error, + classificationFailed, ...details, unreadableFields: Object.freeze(unreadableFields), stage, diff --git a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js index f1e9c9c..f55bdad 100644 --- a/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js +++ b/test/dom/binance-strategy29-bollinger/tradingview-bearish-alerts.test.js @@ -711,7 +711,7 @@ for (const stage of ['export', 'render']) { assert.equal(harness.monitor.diagnostics.cleanupPending, false); assert.equal(harness.monitor.diagnostics.layerSize, 0); const expected = { - thrownType: 'object', name: 'Error', message, unreadableFields: [], stage, routeSymbol: 'BTRUSDT', resolution: '1', + thrownType: 'object', classificationFailed: false, name: 'Error', message, unreadableFields: [], stage, routeSymbol: 'BTRUSDT', resolution: '1', cachedSignalCount: 6, layerSizeBeforeCleanup: 6, sessionRevision: 0, contextIntervalRevision: 0, }; @@ -734,6 +734,7 @@ for (const value of ['x'.repeat(600), null, { code: 7 }]) { assert.equal(harness.monitor.diagnostics.cleanupPending, false); assert.deepEqual(harness.monitor.diagnostics.lastLocalFailure, { thrownType: value === null ? 'null' : typeof value, + classificationFailed: false, name: null, message: typeof value === 'string' ? 'x'.repeat(512) : null, unreadableFields: [], stage: 'export', routeSymbol: 'BTRUSDT', resolution: '1', cachedSignalCount: null, layerSizeBeforeCleanup: 0, @@ -746,6 +747,61 @@ for (const value of ['x'.repeat(600), null, { code: 7 }]) { }); } +test('keeps a recoverable snapshot race out of fatal diagnostics and resumes exports', async () => { + const fixture = createChartDom(); + const harness = createMonitorHarness(fixture); + const stable = async () => exportResult([{ 0: 60, 1: 10, 2: 12, 3: 9, 4: 11 }]); + fixture.chart.exportData = stable; + await harness.tick(); + const failure = new TradingViewBarSnapshotInconsistentError('synthetic snapshot race'); + fixture.chart.exportData = async () => { throw failure; }; + await assert.rejects(harness.tick(), reason => reason === failure); + assert.equal(harness.monitor.diagnostics.failed, false); + assert.equal(harness.monitor.diagnostics.cleanupPending, false); + assert.equal(harness.monitor.diagnostics.lastLocalFailure, null); + assert.equal(harness.monitor.diagnostics.layerSize, 1); + fixture.chart.exportData = stable; + await harness.tick(); + assert.equal(harness.monitor.diagnostics.cachedSignalCount, 1); + assert.equal(harness.monitor.diagnostics.lastLocalFailure, null); + assert.deepEqual(harness.errors, []); + harness.monitor.stop(); + fixture.dom.window.close(); +}); + +for (const revoked of [true, false]) { + test(`stops an unclassifiable host Proxy rejection (revoked=${revoked})`, async () => { + const fixture = createChartDom(); + const harness = createMonitorHarness(fixture); + const host = Proxy.revocable({}, { getPrototypeOf() { + throw new Error('synthetic host prototype failure'); + } }); + if (revoked) host.revoke(); + let exports = 0; + fixture.chart.exportData = async () => { exports += 1; throw host.proxy; }; + // assert.rejects itself reads properties of the rejected value, which a revoked Proxy forbids. + await harness.tick().then( + () => assert.fail('The original host rejection must propagate'), + reason => assert.equal(reason, host.proxy), + ); + await harness.tick(); + await harness.tick(); + const recorded = harness.monitor.diagnostics.lastLocalFailure; + assert.equal(recorded.classificationFailed, true); + assert.equal(recorded.thrownType, 'object'); + assert.equal(recorded.name, null); + assert.equal(recorded.message, null); + assert.deepEqual(recorded.unreadableFields, revoked ? ['name', 'message'] : []); + assert.equal(harness.monitor.diagnostics.failed, true); + assert.equal(harness.monitor.diagnostics.cleanupPending, false); + assert.equal(harness.monitor.diagnostics.layerSize, 0); + assert.equal(harness.errors[0][1], host.proxy); + assert.equal(exports, 1); + harness.monitor.stop(); + fixture.dom.window.close(); + }); +} + for (const throwing of [true, false]) { test(`reads host rejection accessors once and records unreadable fields (throwing=${throwing})`, async () => { const fixture = createChartDom();