gloomberb 0.19.0 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +2 -1
- package/patches/electrobun@1.18.1.patch +23 -1
- package/src/api-client/app-attention.ts +3 -0
- package/src/api-client/beneficial-owners.ts +3 -0
- package/src/api-client/company-kpis.ts +3 -0
- package/src/api-client/data.ts +2 -1
- package/src/api-client/errors.ts +38 -0
- package/src/api-client/hiring.ts +3 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/ipo.ts +24 -1
- package/src/api-client/mna.ts +3 -0
- package/src/api-client/paths.ts +9 -1
- package/src/api-client/roadmap.ts +85 -0
- package/src/api-client/types.ts +12 -0
- package/src/app/runtime/startup.ts +3 -1
- package/src/architecture/core-builtin-allowlist.json +2 -2
- package/src/capabilities/chart-series.ts +2 -1
- package/src/cli/commands/market.ts +44 -16
- package/src/cli/commands/news-search.ts +22 -0
- package/src/cli/commands/overview.ts +84 -19
- package/src/cli/commands/search.ts +13 -13
- package/src/cli/commands/ticker.ts +32 -2
- package/src/cli/context.ts +1 -0
- package/src/cli/errors.ts +17 -2
- package/src/cli/glossary.ts +8 -1
- package/src/cli/history-rows.ts +35 -7
- package/src/cli/index-roots.ts +0 -7
- package/src/cli/known-gaps.ts +11 -0
- package/src/cli/listing-arg.ts +74 -11
- package/src/cli/pane-functions/capabilities.ts +1 -0
- package/src/cli/pane-functions/discovery.ts +21 -1
- package/src/cli/pane-functions/freshness.ts +7 -1
- package/src/cli/pane-functions/function-help.ts +7 -0
- package/src/cli/pane-functions/index.ts +6 -3
- package/src/cli/quote-answer.ts +30 -7
- package/src/cli/saved-copy-persistence.ts +88 -0
- package/src/components/command-bar/list/model.ts +5 -0
- package/src/components/command-bar/routes/root/runtime.ts +29 -8
- package/src/components/command-bar/selection-runtime.ts +64 -32
- package/src/components/command-bar/surface/index.tsx +2 -0
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/header.tsx +2 -2
- package/src/components/layout/pane/capture-stamp.ts +2 -2
- package/src/components/layout/pane/footer/index.tsx +23 -6
- package/src/components/layout/pane/footer/model.ts +116 -16
- package/src/components/layout/pane/header.tsx +2 -2
- package/src/components/layout/pane/status-footer.ts +37 -3
- package/src/components/layout/status-widget-space.ts +9 -0
- package/src/components/onboarding/onboarding-frame.tsx +2 -1
- package/src/components/onboarding/onboarding-wizard.tsx +6 -3
- package/src/components/ticker/list-table-view.tsx +4 -20
- package/src/components/ui/display.tsx +29 -3
- package/src/components/ui/icon.tsx +12 -3
- package/src/core/app-services.ts +11 -2
- package/src/data/app-persistence.ts +9 -7
- package/src/data/plugin-cache.ts +20 -3
- package/src/data/plugin-state-store.ts +81 -24
- package/src/data/resource-store.ts +130 -58
- package/src/data/saved-copies.ts +8 -0
- package/src/data/session-store.ts +31 -8
- package/src/data/sqlite/access.ts +313 -0
- package/src/data/sqlite/checkpointer.ts +198 -0
- package/src/data/sqlite/database.ts +47 -4
- package/src/data/sqlite/retry.ts +50 -9
- package/src/data/ticker-store.ts +57 -23
- package/src/docs/snapshot.json +32 -5
- package/src/i18n/es.ts +2 -4
- package/src/i18n/ja.ts +2 -4
- package/src/i18n/ko.ts +2 -4
- package/src/i18n/zh-cn.ts +2 -4
- package/src/i18n/zh-tw.ts +2 -4
- package/src/market-data/depositary-pair.ts +25 -1
- package/src/market-data/quotes/metadata.ts +7 -0
- package/src/market-data/realized-volatility.ts +4 -2
- package/src/news/aggregator.ts +7 -1
- package/src/news/types.ts +6 -0
- package/src/plugins/auto-update-node.ts +10 -6
- package/src/plugins/builtin/alerts/calendar-events.ts +10 -4
- package/src/plugins/builtin/alerts/cli.ts +34 -11
- package/src/plugins/builtin/alerts/event-rows.ts +24 -3
- package/src/plugins/builtin/alerts/events-pane.tsx +15 -6
- package/src/plugins/builtin/alerts/headless.ts +10 -6
- package/src/plugins/builtin/cds/client.ts +142 -30
- package/src/plugins/builtin/cds/headless.ts +35 -8
- package/src/plugins/builtin/cds/model.ts +145 -18
- package/src/plugins/builtin/cds/pane.tsx +16 -5
- package/src/plugins/builtin/central-bank-rates/series.ts +2 -3
- package/src/plugins/builtin/chart-composer/cli-options.ts +16 -0
- package/src/plugins/builtin/chart-composer/follow-binding.ts +14 -2
- package/src/plugins/builtin/chart-composer/headless-schema.ts +5 -0
- package/src/plugins/builtin/chart-composer/index.tsx +4 -5
- package/src/plugins/builtin/chart-composer/pane.tsx +5 -1
- package/src/plugins/builtin/chart-composer/session-range.ts +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +5 -1
- package/src/plugins/builtin/chat/controller/notifications.ts +33 -3
- package/src/plugins/builtin/chat/controller/state.ts +25 -2
- package/src/plugins/builtin/chat/controller/unread-people.ts +89 -23
- package/src/plugins/builtin/chat/controller/utils.ts +7 -2
- package/src/plugins/builtin/chat/status-preview.ts +79 -0
- package/src/plugins/builtin/chat/status-widget.tsx +147 -10
- package/src/plugins/builtin/chat/unread-badge.ts +51 -10
- package/src/plugins/builtin/clinical-trials/client.ts +49 -4
- package/src/plugins/builtin/clinical-trials/model.ts +86 -22
- package/src/plugins/builtin/clinical-trials/pane.tsx +46 -27
- package/src/plugins/builtin/clinical-trials/sponsor-listing.ts +90 -0
- package/src/plugins/builtin/clinical-trials/sponsor-tickers.ts +87 -9
- package/src/plugins/builtin/clinical-trials/trial-pages.ts +50 -0
- package/src/plugins/builtin/commodity-balances/client.ts +4 -1
- package/src/plugins/builtin/company-attention/headless.ts +2 -1
- package/src/plugins/builtin/company-attention/pane.tsx +3 -0
- package/src/plugins/builtin/company-kpis/headless.ts +8 -3
- package/src/plugins/builtin/company-kpis/model.ts +73 -5
- package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
- package/src/plugins/builtin/composite-plugins.ts +2 -1
- package/src/plugins/builtin/cot/client.ts +28 -2
- package/src/plugins/builtin/cot/headless.ts +4 -1
- package/src/plugins/builtin/cot/index.tsx +4 -2
- package/src/plugins/builtin/cot/model.ts +58 -7
- package/src/plugins/builtin/cot/pane.tsx +6 -2
- package/src/plugins/builtin/cpi/client.ts +6 -5
- package/src/plugins/builtin/credit-boards/series.ts +3 -4
- package/src/plugins/builtin/credit-conditions/client.ts +22 -14
- package/src/plugins/builtin/credit-documents/headless.ts +3 -3
- package/src/plugins/builtin/crypto-board/client.ts +5 -5
- package/src/plugins/builtin/crypto-board/series.ts +4 -4
- package/src/plugins/builtin/debt-maturities/series.ts +3 -3
- package/src/plugins/builtin/doe/client.ts +6 -5
- package/src/plugins/builtin/doe/headless.ts +3 -2
- package/src/plugins/builtin/doe/pane.tsx +13 -3
- package/src/plugins/builtin/doe/prices.ts +2 -3
- package/src/plugins/builtin/doe/series.ts +4 -4
- package/src/plugins/builtin/earnings-calls/calendar.tsx +10 -2
- package/src/plugins/builtin/earnings-calls/data.ts +37 -17
- package/src/plugins/builtin/earnings-calls/headless.ts +13 -3
- package/src/plugins/builtin/earnings-calls/pane.tsx +34 -14
- package/src/plugins/builtin/econ/calendar-model.ts +48 -1
- package/src/plugins/builtin/econ/calendar-source.ts +4 -2
- package/src/plugins/builtin/econ/detail-view.tsx +18 -4
- package/src/plugins/builtin/econ/figures.ts +48 -0
- package/src/plugins/builtin/econ/index.tsx +14 -5
- package/src/plugins/builtin/econ/print-watch.ts +3 -1
- package/src/plugins/builtin/econ-statistics/client.ts +11 -0
- package/src/plugins/builtin/econ-statistics/headless.ts +10 -2
- package/src/plugins/builtin/econ-statistics/pane.tsx +8 -4
- package/src/plugins/builtin/econ-statistics/stats.ts +3 -0
- package/src/plugins/builtin/executives/data.ts +6 -1
- package/src/plugins/builtin/executives/pane.tsx +9 -21
- package/src/plugins/builtin/fear-greed/cache.ts +4 -0
- package/src/plugins/builtin/fear-greed/series.ts +6 -4
- package/src/plugins/builtin/gpu/client.ts +4 -1
- package/src/plugins/builtin/gpu/series.ts +2 -3
- package/src/plugins/builtin/help/index.tsx +61 -59
- package/src/plugins/builtin/help/shortcut-table.tsx +5 -6
- package/src/plugins/builtin/help/table-cells.tsx +13 -2
- package/src/plugins/builtin/holders/beneficial-client.ts +5 -1
- package/src/plugins/builtin/holders/beneficial-view.tsx +4 -2
- package/src/plugins/builtin/holders/headless.ts +4 -2
- package/src/plugins/builtin/holders/pane.tsx +3 -0
- package/src/plugins/builtin/holders/report-rows.ts +4 -1
- package/src/plugins/builtin/insider/headless.ts +2 -2
- package/src/plugins/builtin/ipo-calendar/client.ts +18 -2
- package/src/plugins/builtin/ipo-calendar/headless.ts +75 -11
- package/src/plugins/builtin/ipo-calendar/listing-ipo.ts +160 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +98 -2
- package/src/plugins/builtin/jodi/client.ts +4 -1
- package/src/plugins/builtin/macro-day/headless.ts +12 -5
- package/src/plugins/builtin/macro-day/index.tsx +1 -1
- package/src/plugins/builtin/macro-day/model.ts +39 -5
- package/src/plugins/builtin/macro-day/pane.tsx +27 -19
- package/src/plugins/builtin/market-movers/client.ts +3 -0
- package/src/plugins/builtin/market-movers/headless.ts +3 -1
- package/src/plugins/builtin/market-movers/index.tsx +10 -5
- package/src/plugins/builtin/market-movers/screener.ts +27 -6
- package/src/plugins/builtin/market-movers/session-body.tsx +2 -1
- package/src/plugins/builtin/mna/headless.ts +2 -0
- package/src/plugins/builtin/mna/pane.tsx +3 -1
- package/src/plugins/builtin/money-markets/series.ts +3 -5
- package/src/plugins/builtin/news/headless.ts +3 -1
- package/src/plugins/builtin/news/index.tsx +3 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +2 -6
- package/src/plugins/builtin/portfolio-list/quick-add/index.tsx +0 -14
- package/src/plugins/builtin/portfolio-list/quick-add/resolution.ts +2 -1
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -6
- package/src/plugins/builtin/power/gas-client.ts +2 -1
- package/src/plugins/builtin/power/prices-client.ts +3 -2
- package/src/plugins/builtin/realized-vol/client.ts +13 -5
- package/src/plugins/builtin/realized-vol/evidence.ts +29 -4
- package/src/plugins/builtin/realized-vol/headless.ts +8 -4
- package/src/plugins/builtin/realized-vol/model.ts +37 -6
- package/src/plugins/builtin/realized-vol/pane.tsx +9 -7
- package/src/plugins/builtin/research/event-model.ts +30 -4
- package/src/plugins/builtin/research/index.tsx +7 -3
- package/src/plugins/builtin/research/relative-valuation-headless.ts +8 -25
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +52 -15
- package/src/plugins/builtin/research/relative-valuation-peers.ts +217 -20
- package/src/plugins/builtin/research/relative-valuation-settings.ts +44 -0
- package/src/plugins/builtin/risk-factors/data.ts +12 -6
- package/src/plugins/builtin/risk-factors/headless.ts +5 -2
- package/src/plugins/builtin/risk-factors/index.tsx +3 -1
- package/src/plugins/builtin/risk-factors/pane.tsx +5 -8
- package/src/plugins/builtin/roadmap/index.tsx +28 -0
- package/src/plugins/builtin/roadmap/model.ts +67 -0
- package/src/plugins/builtin/roadmap/pane.tsx +244 -0
- package/src/plugins/builtin/scanner/live-headless.ts +21 -2
- package/src/plugins/builtin/scanner/lock-note.ts +26 -3
- package/src/plugins/builtin/sec/headless.ts +2 -1
- package/src/plugins/builtin/sec/index.tsx +3 -0
- package/src/plugins/builtin/shared/cloud-resource.ts +20 -7
- package/src/plugins/builtin/shared/fred-points.ts +6 -1
- package/src/plugins/builtin/shared/headless-market-data.ts +2 -2
- package/src/plugins/builtin/shared/pro-lock-note.ts +23 -8
- package/src/plugins/builtin/shared/report-freshness.ts +8 -0
- package/src/plugins/builtin/shared/research-cells.tsx +1 -1
- package/src/plugins/builtin/shared/series-cache.ts +4 -4
- package/src/plugins/builtin/shared/series-warning.ts +13 -4
- package/src/plugins/builtin/short-volume/series.ts +2 -3
- package/src/plugins/builtin/social-mentions/client.ts +15 -10
- package/src/plugins/builtin/social-mentions/series.ts +2 -3
- package/src/plugins/builtin/thirteenf/api.ts +15 -9
- package/src/plugins/builtin/thirteenf/data.ts +39 -11
- package/src/plugins/builtin/thirteenf/headless.ts +31 -4
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +5 -1
- package/src/plugins/builtin/thirteenf/pane.tsx +21 -5
- package/src/plugins/builtin/thirteenf/types.ts +2 -0
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -2
- package/src/plugins/builtin/ticker-detail/financials/model.ts +35 -2
- package/src/plugins/builtin/ticker-detail/financials/ratios.ts +53 -18
- package/src/plugins/builtin/ticker-detail/headless.ts +59 -8
- package/src/plugins/builtin/ticker-detail/overview/model.ts +10 -2
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +2 -1
- package/src/plugins/builtin/ticker-detail/pane.tsx +11 -6
- package/src/plugins/builtin/treasury-auctions/cache.ts +20 -8
- package/src/plugins/builtin/treasury-auctions/client.ts +3 -3
- package/src/plugins/builtin/treasury-auctions/headless.ts +24 -13
- package/src/plugins/builtin/treasury-auctions/pane.tsx +18 -6
- package/src/plugins/builtin/volatility/client.ts +43 -16
- package/src/plugins/builtin/volatility/headless.ts +5 -2
- package/src/plugins/builtin/volatility/model.ts +17 -6
- package/src/plugins/builtin/volatility/pane.tsx +11 -4
- package/src/plugins/builtin/volatility/series.ts +2 -1
- package/src/plugins/builtin/yield-curve/client.ts +59 -1
- package/src/plugins/builtin/yield-curve/headless.ts +9 -6
- package/src/plugins/builtin/yield-curve/index.tsx +34 -10
- package/src/plugins/builtin/yield-curve/swaps.tsx +9 -5
- package/src/plugins/catalog-browser.ts +2 -1
- package/src/plugins/host-link.ts +1 -1
- package/src/plugins/installer.ts +50 -39
- package/src/portfolio/column-values.ts +11 -7
- package/src/renderers/dom/desktop/icons.tsx +10 -2
- package/src/renderers/electrobun/bun/desktop/backend.ts +9 -10
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +23 -1
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +8 -0
- package/src/renderers/electrobun/bun/desktop/initialization.ts +9 -1
- package/src/renderers/electrobun/bun/desktop/installer-folder.ts +731 -0
- package/src/renderers/electrobun/bun/desktop/relaunch.ts +102 -10
- package/src/renderers/electrobun/bun/desktop/windows-script.ts +92 -0
- package/src/renderers/electrobun/bun/index.ts +26 -2
- package/src/renderers/electrobun/bun/window/attention.ts +54 -0
- package/src/renderers/electrobun/bun/window/create.ts +45 -4
- package/src/renderers/electrobun/bun/window/events.ts +20 -0
- package/src/renderers/electrobun/bun/window/windows-custom-chrome.ts +9 -2
- package/src/renderers/electrobun/bun/window/windows-native.ts +78 -0
- package/src/renderers/electrobun/bun/window/windows-quit.ts +57 -0
- package/src/renderers/electrobun/launcher/preload.ts +13 -0
- package/src/renderers/electrobun/launcher/windows-emulation.ts +50 -0
- package/src/renderers/electrobun/shared/protocol.ts +2 -0
- package/src/renderers/electrobun/view/news-source.ts +2 -0
- package/src/renderers/electrobun/view/ui-host.tsx +16 -1
- package/src/series/resolve.ts +2 -1
- package/src/series/types.ts +5 -0
- package/src/sources/gloomberb-cloud/fred-series.ts +8 -4
- package/src/sources/gloomberb-cloud/index.ts +42 -8
- package/src/telemetry/stall-reports-node.ts +13 -2
- package/src/telemetry/stall-reports.ts +189 -13
- package/src/test-support/outage.ts +8 -0
- package/src/tickers/company-name-listing.ts +110 -40
- package/src/tickers/company-name.ts +71 -2
- package/src/tickers/index-roots.ts +7 -0
- package/src/tickers/listing-fate.ts +2 -2
- package/src/tickers/search/home-listing.ts +93 -8
- package/src/tickers/search/index.ts +7 -3
- package/src/tickers/search/ranking.ts +5 -1
- package/src/tickers/search/upsert.ts +13 -0
- package/src/tickers/ticker-changes.ts +8 -4
- package/src/time-series/market.ts +5 -3
- package/src/time-series/price-comparison.ts +20 -2
- package/src/time-series/resolve.ts +20 -1
- package/src/time-series/types.ts +7 -0
- package/src/types/company-from.ts +40 -0
- package/src/types/data-provider.ts +7 -0
- package/src/types/financials.ts +45 -3
- package/src/types/headless.ts +6 -0
- package/src/ui/host.tsx +6 -1
- package/src/utils/crypto-pair.ts +121 -7
- package/src/utils/declared-dividends.ts +1 -1
- package/src/utils/financial-statements.ts +73 -16
- package/src/utils/fundamentals.ts +53 -10
- package/src/utils/lease-liabilities.ts +54 -0
- package/src/utils/main-thread-stall.ts +47 -2
- package/src/utils/perf-marks.ts +23 -11
- package/src/utils/price-history-integrity.ts +47 -1
- package/src/version.ts +2 -2
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { dlopen, ptr, read, type Pointer } from "bun:ffi";
|
|
2
|
+
|
|
3
|
+
// Windows on Arm runs the x64 app under emulation. When a thread exits there,
|
|
4
|
+
// Windows' emulation layer can fault on memory it no longer reaches
|
|
5
|
+
// (ntdll!RtlEcContextToNativeContext) and recovers from that fault itself.
|
|
6
|
+
// Bun 1.3's crash handler is a vectored exception handler, so it sees the
|
|
7
|
+
// fault before Windows does and takes it for a crash. On the exiting thread,
|
|
8
|
+
// which has no thread-local storage left, the handler then faults inside
|
|
9
|
+
// itself until the thread's stack is gone, and the app closes some seconds
|
|
10
|
+
// after it opens. Bun 1.4 only claims faults inside its own code. Until the
|
|
11
|
+
// app runs on it, remove Bun's handler under emulation and leave faults to
|
|
12
|
+
// Windows, which still reports a real crash.
|
|
13
|
+
|
|
14
|
+
// The Bun build Electrobun ships, by link timestamp, and where that build
|
|
15
|
+
// keeps the handle of its vectored exception handler (from its symbols).
|
|
16
|
+
const BUN_HANDLER_HANDLES = new Map<number, number>([
|
|
17
|
+
[0x69e4847f, 0x68e3d40], // Bun 1.3.13, bun-windows-x64-baseline
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const IMAGE_FILE_MACHINE_ARM64 = 0xaa64;
|
|
21
|
+
const CURRENT_PROCESS = -1;
|
|
22
|
+
|
|
23
|
+
export type EmulationFaultsResult = "not emulated" | "other bun build" | "handler already gone" | "handler removed" | "handler not found";
|
|
24
|
+
|
|
25
|
+
export function leaveEmulationFaultsToWindows(): EmulationFaultsResult {
|
|
26
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
27
|
+
GetModuleHandleW: { args: ["ptr"], returns: "ptr" },
|
|
28
|
+
IsWow64Process2: { args: ["i64", "ptr", "ptr"], returns: "i32" },
|
|
29
|
+
RemoveVectoredExceptionHandler: { args: ["ptr"], returns: "u32" },
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
const processMachine = new Uint16Array(1);
|
|
33
|
+
const nativeMachine = new Uint16Array(1);
|
|
34
|
+
if (!kernel32.symbols.IsWow64Process2(CURRENT_PROCESS, ptr(processMachine), ptr(nativeMachine))) return "not emulated";
|
|
35
|
+
if (nativeMachine[0] !== IMAGE_FILE_MACHINE_ARM64) return "not emulated";
|
|
36
|
+
|
|
37
|
+
const image = kernel32.symbols.GetModuleHandleW(null);
|
|
38
|
+
if (!image) return "other bun build";
|
|
39
|
+
const timestamp = read.u32(image, read.u32(image, 0x3c) + 8);
|
|
40
|
+
const handleOffset = BUN_HANDLER_HANDLES.get(timestamp);
|
|
41
|
+
if (handleOffset === undefined) return "other bun build";
|
|
42
|
+
|
|
43
|
+
const handle = read.ptr(image, handleOffset) as Pointer;
|
|
44
|
+
if (!handle) return "handler already gone";
|
|
45
|
+
// Windows only removes a handle it has registered, so a wrong value does nothing.
|
|
46
|
+
return kernel32.symbols.RemoveVectoredExceptionHandler(handle) ? "handler removed" : "handler not found";
|
|
47
|
+
} finally {
|
|
48
|
+
kernel32.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -212,6 +212,8 @@ interface DesktopBackendRequestMap {
|
|
|
212
212
|
"host.exit": { request: null; response: null };
|
|
213
213
|
"host.windowControl": { request: { action: DesktopWindowControlAction }; response: null };
|
|
214
214
|
"host.windowFullscreen": { request: null; response: boolean };
|
|
215
|
+
/** Windows: moves the window with the pointer through the system's own move loop. */
|
|
216
|
+
"host.startWindowDrag": { request: null; response: null };
|
|
215
217
|
"host.openExternal": { request: { url: string }; response: boolean };
|
|
216
218
|
"host.copyText": { request: { text: string }; response: null };
|
|
217
219
|
"host.focusWindow": { request: null; response: null };
|
|
@@ -2,6 +2,7 @@ import { apiClient } from "../../../api-client";
|
|
|
2
2
|
import { newsProvider, type NewsCapability } from "../../../capabilities";
|
|
3
3
|
import { cloudNewsReadArticles, mapCloudNewsArticle, readCloudNews } from "../../../sources/gloomberb-cloud/news";
|
|
4
4
|
import type { NewsArticle, NewsPage, NewsQuery } from "../../../news/types";
|
|
5
|
+
import { readCompanyFrom } from "../../../types/company-from";
|
|
5
6
|
|
|
6
7
|
interface BackendNews {
|
|
7
8
|
id: string;
|
|
@@ -34,6 +35,7 @@ export function createDesktopNewsSource(backend: BackendNews, client: CloudNewsC
|
|
|
34
35
|
articles: cloudNewsReadArticles(read, query.ticker),
|
|
35
36
|
nextCursor: read.nextCursor ?? null,
|
|
36
37
|
access: read.access ?? null,
|
|
38
|
+
companyFrom: readCompanyFrom(read.companyFrom),
|
|
37
39
|
};
|
|
38
40
|
} catch {
|
|
39
41
|
// The aggregate page keeps RSS failures beside any usable stories.
|
|
@@ -5,6 +5,7 @@ import { backendRequest } from "./backend-rpc";
|
|
|
5
5
|
import { NATIVE_CONTEXT_MENU_SUPPORTED, showDesktopContextMenu } from "./host/native";
|
|
6
6
|
import { startElectrobunWindowDrag } from "./host/window-move";
|
|
7
7
|
import { createDomUiHost } from "../../dom/dom-ui-host";
|
|
8
|
+
import { detectPlatform } from "../../../utils/platform";
|
|
8
9
|
|
|
9
10
|
export function createWebUiHost(desktopPlatform?: string): UiHost {
|
|
10
11
|
return createDomUiHost(desktopPlatform, {
|
|
@@ -24,7 +25,21 @@ export const webRendererHost: RendererHost = {
|
|
|
24
25
|
void backendRequest("host.exit").catch(() => window.close());
|
|
25
26
|
});
|
|
26
27
|
},
|
|
27
|
-
startWindowDrag() {
|
|
28
|
+
startWindowDrag(press) {
|
|
29
|
+
// Windows moves the window itself, as it does from a native title bar, and
|
|
30
|
+
// a double-click there maximizes or restores it.
|
|
31
|
+
if (detectPlatform() === "win32") {
|
|
32
|
+
if (press?.detail === 2) {
|
|
33
|
+
void backendRequest("host.windowControl", { action: "toggle-maximize" }).catch((error: unknown) => {
|
|
34
|
+
console.error("[desktop] could not maximize the window", error instanceof Error ? error.message : String(error));
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
void backendRequest("host.startWindowDrag").catch((error: unknown) => {
|
|
39
|
+
console.error("[desktop] could not start moving the window", error instanceof Error ? error.message : String(error));
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
28
43
|
startElectrobunWindowDrag();
|
|
29
44
|
},
|
|
30
45
|
async controlWindow(action) {
|
package/src/series/resolve.ts
CHANGED
|
@@ -195,7 +195,7 @@ export async function resolveLoadedSeries(
|
|
|
195
195
|
): Promise<ResolvedSeries> {
|
|
196
196
|
const ticker = request.ticker?.trim().toUpperCase() || undefined;
|
|
197
197
|
const line = definition.lines?.length ? request.line ?? definition.lines[0] : undefined;
|
|
198
|
-
const { points, edge, warning, attribution } = loadResult(await loadSeries(definition, {
|
|
198
|
+
const { points, edge, warning, savedAt, attribution } = loadResult(await loadSeries(definition, {
|
|
199
199
|
...seriesLoadWindow(request.viewport, now),
|
|
200
200
|
...(ticker ? { ticker } : {}),
|
|
201
201
|
...viewportBars(request.viewport, ticker),
|
|
@@ -221,6 +221,7 @@ export async function resolveLoadedSeries(
|
|
|
221
221
|
panelId: "main",
|
|
222
222
|
interpolation: style === "step" ? "step-after" : "none",
|
|
223
223
|
...(warning ? { warning } : {}),
|
|
224
|
+
...(savedAt != null && Number.isFinite(savedAt) ? { savedAt } : {}),
|
|
224
225
|
...(attribution ? { attribution: { ...attribution } } : {}),
|
|
225
226
|
points: normalizeSeriesPoints(points, edge),
|
|
226
227
|
};
|
package/src/series/types.ts
CHANGED
|
@@ -92,6 +92,11 @@ export interface SeriesLoadResult {
|
|
|
92
92
|
points: readonly SeriesPoint[];
|
|
93
93
|
/** A note the chart shows above the plot, such as "cached data is stale". */
|
|
94
94
|
warning?: string;
|
|
95
|
+
/**
|
|
96
|
+
* When the copy the points came from was saved (ms), when its refresh failed
|
|
97
|
+
* and that copy answered: the chart's footer says "saved copy from …".
|
|
98
|
+
*/
|
|
99
|
+
savedAt?: number;
|
|
95
100
|
/** What a report of this series cites in place of Gloom Cloud. */
|
|
96
101
|
attribution?: SeriesAttribution;
|
|
97
102
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createPluginCache } from "../../data/plugin-cache";
|
|
1
|
+
import { createPluginCache, savedCopyForTransientFailure } from "../../data/plugin-cache";
|
|
2
2
|
import { getPublishedUsEquityCalendarDay } from "../../market-data/published-us-sessions";
|
|
3
3
|
import { zonedDateKey } from "../../utils/zoned-date-time";
|
|
4
4
|
import type {
|
|
@@ -42,6 +42,8 @@ export interface FredSeriesCacheEntry {
|
|
|
42
42
|
export interface FredSeriesLoadResult extends FredSeriesCacheEntry {
|
|
43
43
|
source: "cache" | "network" | "stale-fallback";
|
|
44
44
|
refreshError?: string;
|
|
45
|
+
/** What the failed refresh threw, when a saved copy answered (`stale-fallback`). */
|
|
46
|
+
error?: unknown;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
const cache = createPluginCache<FredSeriesData>({
|
|
@@ -196,7 +198,9 @@ export async function loadCachedFredSeries(
|
|
|
196
198
|
const cached = stored ? withFredSourceFreshness(stored) : null;
|
|
197
199
|
if (!options?.force && !pending && cached && !cached.stale) return { ...cached, source: "cache" };
|
|
198
200
|
const result = await cache.load(cacheKey(request), loader, pending ? { ...options, force: true } : options);
|
|
199
|
-
// An early re-read is opportunistic: a failure keeps the still-fresh copy as it was.
|
|
200
|
-
if (pending && result.
|
|
201
|
-
|
|
201
|
+
// An early re-read is opportunistic: a failure keeps the still-fresh copy as it was, as not asking would have.
|
|
202
|
+
if (pending && result.source === "stale-fallback" && cached && !cached.stale && !options?.force) return { ...cached, source: "cache" };
|
|
203
|
+
// A refusal (a series no longer served, a signed-out session) or a reply that fails validation is thrown,
|
|
204
|
+
// whatever copy is saved: a saved copy answers only for a failure asking again may get past.
|
|
205
|
+
return withFredSourceFreshness(savedCopyForTransientFailure(result));
|
|
202
206
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { logFailure } from "../../utils/log-failure";
|
|
2
|
+
import { readCompanyFrom } from "../../types/company-from";
|
|
2
3
|
import { exchangeRateMetadata } from "../../utils/exchange-rate-snapshot";
|
|
3
4
|
import type { ExchangeRateSnapshot } from "../../types/exchange-rate";
|
|
4
5
|
import type { TimeRange } from "../../time-series/range";
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
import type { NewsArticle, NewsQuery } from "../../types/news-source";
|
|
43
44
|
import { normalizeNewsFeed } from "../../news/news-model";
|
|
44
45
|
import { resolveCurrencyUnit } from "../../utils/currency-units";
|
|
45
|
-
import { isRoundTheClockCoin } from "../../utils/crypto-pair";
|
|
46
|
+
import { exchangeLineCoinListing, isRoundTheClockCoin, withExchangeLinesAsCoinListings } from "../../utils/crypto-pair";
|
|
46
47
|
import { canonicalExchange, canonicalTickerKey, parsePublicTickerKey } from "../../utils/exchanges";
|
|
47
48
|
import { normalizePriceHistory, priceHistoryIntervalMs, reachesLatestSettledSession } from "../../utils/price-history";
|
|
48
49
|
import { createProviderMiss, ProviderBusyError, providerMissReason, StaleHistoryError } from "../provider-errors";
|
|
@@ -315,14 +316,35 @@ function cloudResponseTargetKey(key: string, requested: Set<string>): string | u
|
|
|
315
316
|
|
|
316
317
|
/**
|
|
317
318
|
* The listing as Gloom Cloud knows it. A Hong Kong code goes out with its four
|
|
318
|
-
* digits: Cloud has no 700 or 5 on HKEX, only 0700 and 0005.
|
|
319
|
+
* digits: Cloud has no 700 or 5 on HKEX, only 0700 and 0005. A crypto
|
|
320
|
+
* exchange's line of a coin pair (BTC/GBP on COINBASE PRO), which no source
|
|
321
|
+
* quotes, goes out as the crypto venue's listing of the pair, BTC-GBP on CCC.
|
|
319
322
|
*/
|
|
320
323
|
function cloudInstrumentTarget(symbol: string, exchange?: string) {
|
|
321
324
|
rejectPredictionMarket(symbol, exchange);
|
|
322
325
|
const target = publicListingTarget(symbol, exchange);
|
|
326
|
+
const coin = exchangeLineCoinListing(target.symbol, target.exchange);
|
|
327
|
+
if (coin) return { symbol: coin.symbol, exchange: coin.exchange };
|
|
323
328
|
return target.exchange ? { ...target, symbol: hongKongListingCode(target.symbol, target.exchange) } : target;
|
|
324
329
|
}
|
|
325
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Whether a quote names a listing other than the one asked for: asked for BHP
|
|
333
|
+
* on Cboe Australia, Gloom Cloud has answered with BHP on NYSE, in dollars. A
|
|
334
|
+
* price for another instrument is worse than none. A quote that names no
|
|
335
|
+
* listing is judged by its symbol alone, as the router judges it.
|
|
336
|
+
*/
|
|
337
|
+
function pricesOtherListing(quote: Quote, target: ReturnType<typeof cloudInstrumentTarget>): boolean {
|
|
338
|
+
const metadata = quoteMetadataFromQuote(quote);
|
|
339
|
+
const named = !!(metadata.listingExchangeName || parsePublicTickerKey(quote.symbol).exchange);
|
|
340
|
+
return !quoteMetadataMatchesTarget(metadata, named ? target.symbol : parsePublicTickerKey(target.symbol).symbol, named ? target.exchange : undefined);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function otherListingMiss(ticker: string, quote: Quote) {
|
|
344
|
+
const venue = quote.listingExchangeName || quote.exchangeName || parsePublicTickerKey(quote.symbol).exchange;
|
|
345
|
+
return createProviderMiss(`Cloud quoted ${quote.symbol}${venue ? ` on ${venue}` : ""} for ${ticker}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
326
348
|
function cloudHistoryRecovery(
|
|
327
349
|
context: MarketDataRequestContext | undefined,
|
|
328
350
|
ticker: string, symbol: string, exchange: string, interval: string, start: Date, end: Date,
|
|
@@ -520,10 +542,12 @@ export class GloomberbCloudProvider implements AssetDataProvider {
|
|
|
520
542
|
return withCloudFallback(
|
|
521
543
|
async () => {
|
|
522
544
|
const response = await apiClient.getCloudQuote(target.symbol, target.exchange);
|
|
523
|
-
const
|
|
545
|
+
const answered = mapQuote(
|
|
524
546
|
unwrapRequiredCloudResponse(response, `Cloud quotes are unavailable for ${ticker}`),
|
|
525
547
|
response.providerMeta,
|
|
526
|
-
)
|
|
548
|
+
);
|
|
549
|
+
if (pricesOtherListing(answered, target)) throw otherListingMiss(ticker, answered);
|
|
550
|
+
const quote = retainRequestedQuoteSymbol(answered, ticker);
|
|
527
551
|
// A stale answer is the last price the service has. It stays flagged, so
|
|
528
552
|
// no freshness check passes it and only a request with no current quote
|
|
529
553
|
// from any source falls back to it.
|
|
@@ -585,7 +609,11 @@ export class GloomberbCloudProvider implements AssetDataProvider {
|
|
|
585
609
|
return { target, quote: null, error: createProviderMiss(`Cloud quotes are stale for ${target.symbol}`) };
|
|
586
610
|
}
|
|
587
611
|
if ((item.status === "success" || item.status === "partial") && item.data) {
|
|
588
|
-
const
|
|
612
|
+
const answered = mapQuote(item.data);
|
|
613
|
+
if (pricesOtherListing(answered, cloudInstrumentTarget(target.symbol, target.exchange))) {
|
|
614
|
+
return { target, quote: null, error: otherListingMiss(target.symbol, answered) };
|
|
615
|
+
}
|
|
616
|
+
const quote = retainRequestedQuoteSymbol(answered, target.symbol);
|
|
589
617
|
return { target, quote: item.stale === true ? { ...quote, stale: true } : quote };
|
|
590
618
|
}
|
|
591
619
|
return {
|
|
@@ -617,8 +645,9 @@ export class GloomberbCloudProvider implements AssetDataProvider {
|
|
|
617
645
|
// Perpetual markets are listings too, asked for beside the others. They are optional: a failed lookup leaves the
|
|
618
646
|
// other results, and a keystroke waits for them only briefly once the listings are in, unless the query names one.
|
|
619
647
|
const pending = searchPerpetualMarkets(query).catch(logFailure("sources.gloomberb-cloud.index", "Searching perpetual markets failed", [] as InstrumentSearchResult[]));
|
|
648
|
+
// A crypto exchange's line of a coin pair, which no source quotes, is offered as the crypto venue's listing.
|
|
620
649
|
const listings = await withCloudFallback(
|
|
621
|
-
() => apiClient.searchInstruments(query, context?.limit ?? 10),
|
|
650
|
+
async () => withExchangeLinesAsCoinListings(await apiClient.searchInstruments(query, context?.limit ?? 10)),
|
|
622
651
|
"Cloud search is unavailable",
|
|
623
652
|
).catch((error: unknown) => {
|
|
624
653
|
// Rate limited or overloaded: the search was not run, which is not a search that found nothing.
|
|
@@ -637,9 +666,12 @@ export class GloomberbCloudProvider implements AssetDataProvider {
|
|
|
637
666
|
const name = venue ? context?.listingName?.trim() || await this.listingName(ticker, venue) || undefined : undefined;
|
|
638
667
|
const filings = await withCloudFallback(async () => {
|
|
639
668
|
const response = await apiClient.getCloudSecFilings({ ticker, exchange, name, limit: count, offset: 0 });
|
|
640
|
-
|
|
669
|
+
// A foreign line's filings read from its company's home line say whose, each of them.
|
|
670
|
+
const companyFrom = readCompanyFrom(response.companyFrom);
|
|
671
|
+
return response.filings.map((filing) => companyFrom ? { ...mapCloudSecFiling(filing), companyFrom } : mapCloudSecFiling(filing));
|
|
641
672
|
}, `Cloud SEC filings are unavailable for ${ticker}`);
|
|
642
|
-
|
|
673
|
+
// The service matched a line read from its company's home line to the company itself.
|
|
674
|
+
if (!venue || filings.length === 0 || filings[0]!.companyFrom) return filings;
|
|
643
675
|
const symbol = parsePublicTickerKey(ticker).symbol;
|
|
644
676
|
// Without the listing's company the filings cannot be told apart from another company's.
|
|
645
677
|
if (!name) throw createProviderMiss(`SEC filings for ${symbol} could not be matched to its ${venue} listing`);
|
|
@@ -732,6 +764,7 @@ export class GloomberbCloudProvider implements AssetDataProvider {
|
|
|
732
764
|
async searchBatch(queries: string[], options: { limit?: number; signal?: AbortSignal } = {}): Promise<Map<string, InstrumentSearchResult[]> | null> {
|
|
733
765
|
const answers = await cloudSearchBatch(queries, options);
|
|
734
766
|
if (!answers) return null;
|
|
767
|
+
for (const [query, rows] of answers) answers.set(query, withExchangeLinesAsCoinListings(rows));
|
|
735
768
|
// Perpetual markets lead each answer, as `search` gives them, so a symbol in a list resolves as it does alone:
|
|
736
769
|
// one read of the board answers every query's search of it. A query that found no listing is searched again
|
|
737
770
|
// on its own, which finds them then; a board that does not answer leaves them out, as a search does.
|
|
@@ -965,6 +998,7 @@ export function createGloomberbCloudCapabilities(provider = createGloomberbCloud
|
|
|
965
998
|
articles: cloudNewsReadArticles(read, query.ticker),
|
|
966
999
|
nextCursor: read.nextCursor ?? null,
|
|
967
1000
|
access: read.access ?? null,
|
|
1001
|
+
companyFrom: readCompanyFrom(read.companyFrom),
|
|
968
1002
|
};
|
|
969
1003
|
},
|
|
970
1004
|
async fetchNews(query: NewsQuery): Promise<NewsArticle[]> {
|
|
@@ -31,7 +31,18 @@ function parseStallReportState(text: string): StallReportState {
|
|
|
31
31
|
if (typeof at === "number" && Number.isFinite(at)) sent[key] = at;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
return { sent, pending: readCounts(record.pending) };
|
|
34
|
+
return { sent, pending: readCounts(record.pending), recent: readTimes(record.recent) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readTimes(value: unknown): NonNullable<StallReportState["recent"]> {
|
|
38
|
+
const recent: NonNullable<StallReportState["recent"]> = {};
|
|
39
|
+
if (!value || typeof value !== "object") return recent;
|
|
40
|
+
for (const [process, times] of Object.entries(value as Record<string, unknown>)) {
|
|
41
|
+
if (!Array.isArray(times)) continue;
|
|
42
|
+
const kept = times.filter((at): at is number => typeof at === "number" && Number.isFinite(at));
|
|
43
|
+
if (kept.length > 0) recent[process] = kept;
|
|
44
|
+
}
|
|
45
|
+
return recent;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
function trimLog(path: string): void {
|
|
@@ -62,7 +73,7 @@ export function createNodeStallReporterHost(options: {
|
|
|
62
73
|
},
|
|
63
74
|
load: () => {
|
|
64
75
|
const path = pathFor(STATE_FILE);
|
|
65
|
-
if (!path || !existsSync(path)) return { sent: {}, pending: {} };
|
|
76
|
+
if (!path || !existsSync(path)) return { sent: {}, pending: {}, recent: {} };
|
|
66
77
|
return parseStallReportState(readFileSync(path, "utf-8"));
|
|
67
78
|
},
|
|
68
79
|
save: (state) => {
|
|
@@ -22,8 +22,18 @@ import { reportCrash } from "./crash-reports";
|
|
|
22
22
|
* stall and how many there were. No free text: a section name keeps only
|
|
23
23
|
* its fixed leading part, so a ticker in a queue key never leaves.
|
|
24
24
|
*
|
|
25
|
+
* A report also says when in the process's life the stall came (still
|
|
26
|
+
* starting or up, and an uptime bucket), how long before it the named section
|
|
27
|
+
* ended (or that it ended inside it, which makes it the likely cause), and,
|
|
28
|
+
* for the desktop app's own process, whether the app was in front and had a
|
|
29
|
+
* window on screen: a hidden app's slowed timers read like a freeze. A stall
|
|
30
|
+
* after a SQLite statement names the statement (a fixed name), its tries and
|
|
31
|
+
* whether it waited on another program's lock. All of it is buckets and
|
|
32
|
+
* flags.
|
|
33
|
+
*
|
|
25
34
|
* One report per process, bucket, section and app version a day; stalls of
|
|
26
|
-
* the same kind after that are counted and carried in the next report
|
|
35
|
+
* the same kind after that are counted and carried in the next report, and
|
|
36
|
+
* every report says how many stalls the process had in the last day.
|
|
27
37
|
*/
|
|
28
38
|
|
|
29
39
|
/** Which process stalled. */
|
|
@@ -41,9 +51,34 @@ export interface StallReport {
|
|
|
41
51
|
count: number;
|
|
42
52
|
/** The longest of them, rounded to 100 ms. */
|
|
43
53
|
longestMs: number;
|
|
54
|
+
/** This process's stalls of a second or more in the last 24 hours, this one included. */
|
|
55
|
+
day: number;
|
|
56
|
+
/** Whether the process had finished starting. */
|
|
57
|
+
phase: "startup" | "steady" | "unknown";
|
|
58
|
+
uptime: UptimeBucket | "unknown";
|
|
59
|
+
/** How long before the stall began the section ended; "in" when it ended inside it. */
|
|
60
|
+
gap: GapBucket;
|
|
61
|
+
/** The desktop app's own process: whether the app was in front (null: not known yet). */
|
|
62
|
+
active?: boolean | null;
|
|
63
|
+
/** The desktop app's own process: whether a window was open and not minimized. */
|
|
64
|
+
visible?: boolean | null;
|
|
65
|
+
/** After a SQLite statement: how many tries it took. */
|
|
66
|
+
attempts?: number;
|
|
67
|
+
/** After a SQLite statement: whether it waited on another connection's lock. */
|
|
68
|
+
lockWait?: boolean;
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
type StallBucket = "1-2s" | "2-5s" | "5-15s" | "15s+";
|
|
72
|
+
type UptimeBucket = "<30s" | "30s-2m" | "2-10m" | "10-60m" | "1-6h" | "6-24h" | "1d+";
|
|
73
|
+
type GapBucket = "none" | "in" | "<1s" | "1-10s" | "10-60s" | "1-10m" | "10m+";
|
|
74
|
+
|
|
75
|
+
/** What the desktop app's Bun process can tell about whether anyone was looking. */
|
|
76
|
+
export interface StallAttention {
|
|
77
|
+
/** One of the app's windows had the keyboard; null before the first focus change. */
|
|
78
|
+
active: boolean | null;
|
|
79
|
+
/** One of the app's windows was open and not minimized; null when it could not be read. */
|
|
80
|
+
visible: boolean | null;
|
|
81
|
+
}
|
|
47
82
|
|
|
48
83
|
/** What the host keeps on disk between launches. */
|
|
49
84
|
export interface StallReportState {
|
|
@@ -51,6 +86,8 @@ export interface StallReportState {
|
|
|
51
86
|
sent: Record<string, number>;
|
|
52
87
|
/** Stalls counted since each kind was last reported. */
|
|
53
88
|
pending: Record<string, { count: number; longestMs: number }>;
|
|
89
|
+
/** When each process's stalls of a second or more in the last day happened, oldest first. */
|
|
90
|
+
recent?: Record<string, number[]>;
|
|
54
91
|
}
|
|
55
92
|
|
|
56
93
|
export interface StallReporterHost {
|
|
@@ -74,8 +111,13 @@ const SECTION_PATTERN = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*/;
|
|
|
74
111
|
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]{1,60})?$/;
|
|
75
112
|
/** Kinds with counts waiting for their next report; the oldest go first past this. */
|
|
76
113
|
const MAX_KINDS_KEPT = 200;
|
|
114
|
+
/** Stall times kept per process for the day count. */
|
|
115
|
+
const MAX_RECENT_KEPT = 500;
|
|
116
|
+
/** What error tracking keeps of the details line; fields that do not fit are left out. */
|
|
117
|
+
const MAX_DETAILS_LENGTH = 120;
|
|
77
118
|
|
|
78
119
|
let host: StallReporterHost | null = null;
|
|
120
|
+
let attentionProbe: (() => StallAttention) | null = null;
|
|
79
121
|
|
|
80
122
|
/** The fixed part of a section name: lower-case dotted words, cut where anything else starts. */
|
|
81
123
|
function stallSectionName(name: string | undefined): string {
|
|
@@ -85,6 +127,15 @@ function stallSectionName(name: string | undefined): string {
|
|
|
85
127
|
return section && section.length <= 80 ? section : "unmeasured";
|
|
86
128
|
}
|
|
87
129
|
|
|
130
|
+
/** The section a report names; a SQLite statement's adds the statement's fixed name. */
|
|
131
|
+
function reportSection(event: StallEvent): string {
|
|
132
|
+
const section = stallSectionName(event.lastMeasured);
|
|
133
|
+
if (section !== "sqlite.blocked" || !event.sqlite) return section;
|
|
134
|
+
const operation = event.sqlite.operation.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
135
|
+
const named = `${section}.${operation}`.replace(/-+$/, "");
|
|
136
|
+
return SECTION_PATTERN.exec(named)?.[0] === named ? named : section;
|
|
137
|
+
}
|
|
138
|
+
|
|
88
139
|
function stallBucket(blockedMs: number): StallBucket {
|
|
89
140
|
if (blockedMs < 2_000) return "1-2s";
|
|
90
141
|
if (blockedMs < 5_000) return "2-5s";
|
|
@@ -92,15 +143,50 @@ function stallBucket(blockedMs: number): StallBucket {
|
|
|
92
143
|
return "15s+";
|
|
93
144
|
}
|
|
94
145
|
|
|
146
|
+
function uptimeBucket(uptimeMs: number | undefined): UptimeBucket | "unknown" {
|
|
147
|
+
if (uptimeMs === undefined) return "unknown";
|
|
148
|
+
const minutes = uptimeMs / 60_000;
|
|
149
|
+
if (minutes < 0.5) return "<30s";
|
|
150
|
+
if (minutes < 2) return "30s-2m";
|
|
151
|
+
if (minutes < 10) return "2-10m";
|
|
152
|
+
if (minutes < 60) return "10-60m";
|
|
153
|
+
if (minutes < 360) return "1-6h";
|
|
154
|
+
if (minutes < 1_440) return "6-24h";
|
|
155
|
+
return "1d+";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* How long before the stall began the last measured section ended. A
|
|
160
|
+
* section that ended inside the stall ran during it ("in"): the likely
|
|
161
|
+
* cause. Anything after that is time nothing measured ran in.
|
|
162
|
+
*/
|
|
163
|
+
function gapBucket(event: StallEvent): GapBucket {
|
|
164
|
+
if (!event.lastMeasured || event.lastMeasuredEndedMsAgo === undefined) return "none";
|
|
165
|
+
const gapMs = event.lastMeasuredEndedMsAgo - event.blockedMs;
|
|
166
|
+
if (gapMs <= 0) return "in";
|
|
167
|
+
if (gapMs < 1_000) return "<1s";
|
|
168
|
+
if (gapMs < 10_000) return "1-10s";
|
|
169
|
+
if (gapMs < 60_000) return "10-60s";
|
|
170
|
+
if (gapMs < 600_000) return "1-10m";
|
|
171
|
+
return "10m+";
|
|
172
|
+
}
|
|
173
|
+
|
|
95
174
|
/** A stall another process sent over, kept only when it has the right shape. */
|
|
96
175
|
export function parseStallEvent(value: unknown): StallEvent | null {
|
|
97
176
|
if (!value || typeof value !== "object") return null;
|
|
98
|
-
const { blockedMs, lastMeasured, lastMeasuredEndedMsAgo } = value as Record<string, unknown>;
|
|
177
|
+
const { blockedMs, lastMeasured, lastMeasuredEndedMsAgo, sqlite, uptimeMs, phase } = value as Record<string, unknown>;
|
|
99
178
|
if (typeof blockedMs !== "number" || !Number.isFinite(blockedMs) || blockedMs <= 0) return null;
|
|
179
|
+
const detail = sqlite && typeof sqlite === "object" ? sqlite as Record<string, unknown> : null;
|
|
100
180
|
return {
|
|
101
181
|
blockedMs,
|
|
102
182
|
...(typeof lastMeasured === "string" && lastMeasured.length <= 200 ? { lastMeasured } : {}),
|
|
103
183
|
...(typeof lastMeasuredEndedMsAgo === "number" && Number.isFinite(lastMeasuredEndedMsAgo) ? { lastMeasuredEndedMsAgo } : {}),
|
|
184
|
+
...(detail && typeof detail.operation === "string" && detail.operation.length <= 80
|
|
185
|
+
&& typeof detail.attempts === "number" && Number.isFinite(detail.attempts)
|
|
186
|
+
? { sqlite: { operation: detail.operation, attempts: detail.attempts, lockWait: detail.lockWait === true } }
|
|
187
|
+
: {}),
|
|
188
|
+
...(typeof uptimeMs === "number" && Number.isFinite(uptimeMs) && uptimeMs >= 0 ? { uptimeMs } : {}),
|
|
189
|
+
...(phase === "startup" || phase === "steady" ? { phase } : {}),
|
|
104
190
|
};
|
|
105
191
|
}
|
|
106
192
|
|
|
@@ -108,13 +194,43 @@ function formatSeconds(ms: number): string {
|
|
|
108
194
|
return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)} s`;
|
|
109
195
|
}
|
|
110
196
|
|
|
111
|
-
|
|
112
|
-
|
|
197
|
+
function flag(value: boolean | null | undefined): string {
|
|
198
|
+
return value == null ? "?" : value ? "1" : "0";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The context a report carries, also written at the end of the log line. */
|
|
202
|
+
interface StallContext {
|
|
203
|
+
phase: StallReport["phase"];
|
|
204
|
+
uptime: StallReport["uptime"];
|
|
205
|
+
gap: GapBucket;
|
|
206
|
+
attention: StallAttention | null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function stallContext(event: StallEvent, attention: StallAttention | null): StallContext {
|
|
210
|
+
return {
|
|
211
|
+
phase: event.phase ?? "unknown",
|
|
212
|
+
uptime: uptimeBucket(event.uptimeMs),
|
|
213
|
+
gap: gapBucket(event),
|
|
214
|
+
attention,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** One line of the stall log: when, which process, how long, what ran last, and when in the process's life. */
|
|
219
|
+
function formatStallLogLine(process: StallProcess, event: StallEvent, context: StallContext, at: number): string {
|
|
113
220
|
const time = new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
114
221
|
const last = event.lastMeasured
|
|
115
222
|
? `last measured: ${event.lastMeasured}${event.lastMeasuredEndedMsAgo === undefined ? "" : `, ended ${Math.round(event.lastMeasuredEndedMsAgo)} ms before`}`
|
|
116
223
|
: "nothing measured before it";
|
|
117
|
-
|
|
224
|
+
const sqlite = event.sqlite
|
|
225
|
+
? ` (${event.sqlite.operation}, ${event.sqlite.attempts} ${event.sqlite.attempts === 1 ? "try" : "tries"}${event.sqlite.lockWait ? ", waited on a lock" : ""})`
|
|
226
|
+
: "";
|
|
227
|
+
const facts = [
|
|
228
|
+
`phase=${context.phase}`,
|
|
229
|
+
`up=${context.uptime}`,
|
|
230
|
+
`gap=${context.gap}`,
|
|
231
|
+
...(context.attention ? [`fg=${flag(context.attention.active)}`, `vis=${flag(context.attention.visible)}`] : []),
|
|
232
|
+
].join(" ");
|
|
233
|
+
return `${time} ${process.padEnd(8)} froze ${formatSeconds(event.blockedMs).padStart(6)} ${last}${sqlite} [${facts}]`;
|
|
118
234
|
}
|
|
119
235
|
|
|
120
236
|
function reportPlatform(platform: string): StallReport["platform"] {
|
|
@@ -128,13 +244,35 @@ function reportArch(arch: string): StallReport["arch"] {
|
|
|
128
244
|
return arch === "arm64" || arch === "x64" ? arch : "other";
|
|
129
245
|
}
|
|
130
246
|
|
|
131
|
-
|
|
132
|
-
|
|
247
|
+
/**
|
|
248
|
+
* The details line error tracking keeps, most telling first: the original
|
|
249
|
+
* four fields, then the new ones. One that would not fit is left out.
|
|
250
|
+
*/
|
|
251
|
+
export function formatStallReportDetails(report: StallReport): string {
|
|
252
|
+
const fields = [
|
|
133
253
|
`process=${report.process}`,
|
|
134
254
|
`count=${report.count}`,
|
|
135
255
|
`longest=${report.longestMs}ms`,
|
|
136
256
|
`platform=${report.platform}-${report.arch}`,
|
|
137
|
-
|
|
257
|
+
`phase=${report.phase}`,
|
|
258
|
+
`up=${report.uptime}`,
|
|
259
|
+
`gap=${report.gap}`,
|
|
260
|
+
...(report.active === undefined ? [] : [`fg=${flag(report.active)}`]),
|
|
261
|
+
...(report.visible === undefined ? [] : [`vis=${flag(report.visible)}`]),
|
|
262
|
+
...(report.lockWait === undefined ? [] : [`lock=${flag(report.lockWait)}`]),
|
|
263
|
+
...(report.attempts === undefined ? [] : [`try=${report.attempts}`]),
|
|
264
|
+
`day=${report.day}`,
|
|
265
|
+
];
|
|
266
|
+
let details = "";
|
|
267
|
+
for (const field of fields) {
|
|
268
|
+
const next = details ? `${details} ${field}` : field;
|
|
269
|
+
if (next.length <= MAX_DETAILS_LENGTH) details = next;
|
|
270
|
+
}
|
|
271
|
+
return details;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function sendThroughCrashReporter(report: StallReport): void {
|
|
275
|
+
const details = formatStallReportDetails(report);
|
|
138
276
|
// A plain object, not an Error: it has no stack, so no file path goes along.
|
|
139
277
|
// The message is the bucket and section, so error tracking keeps one issue per kind.
|
|
140
278
|
reportCrash(
|
|
@@ -146,10 +284,10 @@ function sendThroughCrashReporter(report: StallReport): void {
|
|
|
146
284
|
function loadState(current: StallReporterHost): StallReportState {
|
|
147
285
|
try {
|
|
148
286
|
const state = current.load();
|
|
149
|
-
return { sent: { ...state.sent }, pending: { ...state.pending } };
|
|
287
|
+
return { sent: { ...state.sent }, pending: { ...state.pending }, recent: { ...state.recent } };
|
|
150
288
|
} catch (error) {
|
|
151
289
|
logFailure("telemetry-stalls", "Could not read stall report state")(error);
|
|
152
|
-
return { sent: {}, pending: {} };
|
|
290
|
+
return { sent: {}, pending: {}, recent: {} };
|
|
153
291
|
}
|
|
154
292
|
}
|
|
155
293
|
|
|
@@ -159,6 +297,22 @@ function prune(state: StallReportState, now: number): void {
|
|
|
159
297
|
}
|
|
160
298
|
const kinds = Object.keys(state.pending);
|
|
161
299
|
for (const key of kinds.slice(0, Math.max(0, kinds.length - MAX_KINDS_KEPT))) delete state.pending[key];
|
|
300
|
+
for (const [process, times] of Object.entries(state.recent ?? {})) {
|
|
301
|
+
const kept = times.filter((at) => now - at < DAY_MS && at <= now);
|
|
302
|
+
if (kept.length > 0) state.recent![process] = kept;
|
|
303
|
+
else delete state.recent![process];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Never throws: a probe that fails reads as not known. */
|
|
308
|
+
function readAttention(): StallAttention | null {
|
|
309
|
+
if (!attentionProbe) return null;
|
|
310
|
+
try {
|
|
311
|
+
return attentionProbe();
|
|
312
|
+
} catch {
|
|
313
|
+
// Intentional ignore: a window that cannot be read leaves the report saying it is not known.
|
|
314
|
+
return { active: null, visible: null };
|
|
315
|
+
}
|
|
162
316
|
}
|
|
163
317
|
|
|
164
318
|
/** Records one stall of `process`. Does nothing where no host is installed; never throws. */
|
|
@@ -167,15 +321,20 @@ export function recordStall(process: StallProcess, event: StallEvent): void {
|
|
|
167
321
|
if (!current || !(event.blockedMs > 0)) return;
|
|
168
322
|
try {
|
|
169
323
|
const now = current.now?.() ?? Date.now();
|
|
170
|
-
|
|
171
|
-
|
|
324
|
+
const reportable = event.blockedMs >= STALL_REPORT_MIN_MS;
|
|
325
|
+
// Asked only of a stall that will be reported: the probe reads the windows.
|
|
326
|
+
const context = stallContext(event, reportable && process === "app" ? readAttention() : null);
|
|
327
|
+
current.appendLog(formatStallLogLine(process, event, context, now));
|
|
328
|
+
if (!reportable) return;
|
|
172
329
|
const appVersion = (current.appVersion ?? VERSION).trim().replace(/^v/, "");
|
|
173
|
-
const section =
|
|
330
|
+
const section = reportSection(event);
|
|
174
331
|
const bucket = stallBucket(event.blockedMs);
|
|
175
332
|
const version = VERSION_PATTERN.test(appVersion) ? appVersion : "unknown";
|
|
176
333
|
const key = `${process}|${bucket}|${section}|${version}`;
|
|
177
334
|
const state = loadState(current);
|
|
178
335
|
prune(state, now);
|
|
336
|
+
const recent = [...(state.recent?.[process] ?? []), now].slice(-MAX_RECENT_KEPT);
|
|
337
|
+
state.recent = { ...state.recent, [process]: recent };
|
|
179
338
|
const pending = state.pending[key] ?? { count: 0, longestMs: 0 };
|
|
180
339
|
pending.count += 1;
|
|
181
340
|
pending.longestMs = Math.max(pending.longestMs, Math.round(event.blockedMs / 100) * 100);
|
|
@@ -193,6 +352,14 @@ export function recordStall(process: StallProcess, event: StallEvent): void {
|
|
|
193
352
|
arch: reportArch(current.arch),
|
|
194
353
|
count: pending.count,
|
|
195
354
|
longestMs: pending.longestMs,
|
|
355
|
+
day: recent.length,
|
|
356
|
+
phase: context.phase,
|
|
357
|
+
uptime: context.uptime,
|
|
358
|
+
gap: context.gap,
|
|
359
|
+
...(context.attention ? { active: context.attention.active, visible: context.attention.visible } : {}),
|
|
360
|
+
...(event.sqlite && section.startsWith("sqlite.blocked")
|
|
361
|
+
? { attempts: Math.max(1, Math.min(99, Math.round(event.sqlite.attempts))), lockWait: event.sqlite.lockWait }
|
|
362
|
+
: {}),
|
|
196
363
|
});
|
|
197
364
|
}
|
|
198
365
|
current.save(state);
|
|
@@ -208,7 +375,16 @@ export function installStallReporter(next: StallReporterHost): () => void {
|
|
|
208
375
|
};
|
|
209
376
|
}
|
|
210
377
|
|
|
378
|
+
/** How the desktop app's Bun process tells whether anyone was looking when it froze (bun/window/attention.ts). */
|
|
379
|
+
export function setStallAttentionProbe(next: (() => StallAttention) | null): () => void {
|
|
380
|
+
attentionProbe = next;
|
|
381
|
+
return () => {
|
|
382
|
+
if (attentionProbe === next) attentionProbe = null;
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
211
386
|
/** Test seam. */
|
|
212
387
|
export function resetStallReporterForTests(): void {
|
|
213
388
|
host = null;
|
|
389
|
+
attentionProbe = null;
|
|
214
390
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request that never reached its server, with `message`: the failure a
|
|
3
|
+
* saved copy answers for (`isTransientFailure`), unlike a plain `Error`, which
|
|
4
|
+
* reads as a reply that failed validation.
|
|
5
|
+
*/
|
|
6
|
+
export function outage(message = "Unable to connect. Is the computer able to access the url?"): Error {
|
|
7
|
+
return Object.assign(new Error(message), { code: "ConnectionRefused" });
|
|
8
|
+
}
|