@spirobel/monero-wallet-api 0.5.4 → 0.5.5

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.
@@ -1 +1 @@
1
- export declare const sleep: (ms: number) => Promise<unknown>;
1
+ export declare const sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
package/dist/io/sleep.js CHANGED
@@ -1 +1,16 @@
1
- export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1
+ export const sleep = (ms, signal) => new Promise((resolve, reject) => {
2
+ if (signal?.aborted) {
3
+ reject(signal.reason ?? new DOMException("aborted", "AbortError"));
4
+ return;
5
+ }
6
+ const onAbort = () => {
7
+ clearTimeout(timer);
8
+ signal?.removeEventListener("abort", onAbort);
9
+ reject(signal?.reason ?? new DOMException("aborted", "AbortError"));
10
+ };
11
+ const timer = setTimeout(() => {
12
+ signal?.removeEventListener("abort", onAbort);
13
+ resolve();
14
+ }, ms);
15
+ signal?.addEventListener("abort", onAbort, { once: true });
16
+ });
@@ -3,7 +3,9 @@ export declare const CPU_POOL_SIZE = 4;
3
3
  export type WorkerSet = {
4
4
  fetchWorker: Worker;
5
5
  cpuWorkers: Worker[];
6
+ shutdown: (timeoutMs?: number) => Promise<void>;
6
7
  terminate: () => void;
8
+ dumpHeaps: (dir?: string) => Promise<string[]>;
7
9
  };
8
10
  export declare function createWebworker(handle_result?: (result: CacheChangedCallbackParameters) => void, scan_settings_path?: string, pathPrefix?: string, handle_error?: (error: unknown) => void): Promise<WorkerSet | undefined>;
9
11
  export declare function makeWebworkerScript(): string;
@@ -46,6 +46,36 @@ export async function createWebworker(handle_result, scan_settings_path, pathPre
46
46
  "coordinator worker started, node_url=" + node_url,
47
47
  ", cpuPorts=" + cpuPorts.length,
48
48
  ]);
49
+ let shutdownResolve;
50
+ let shuttingDown = false;
51
+ const hardTerminate = () => {
52
+ for (const p of cpuPorts) {
53
+ try {
54
+ p.close();
55
+ }
56
+ catch {
57
+ // port may already be closed
58
+ }
59
+ }
60
+ coordinationWorker.onmessage = null;
61
+ coordinationWorker.onerror = null;
62
+ for (const w of cpuWorkers) {
63
+ w.onerror = null;
64
+ w.onmessage = null;
65
+ try {
66
+ w.terminate();
67
+ }
68
+ catch {
69
+ // already dead
70
+ }
71
+ }
72
+ try {
73
+ coordinationWorker.terminate();
74
+ }
75
+ catch {
76
+ // already dead
77
+ }
78
+ };
49
79
  coordinationWorker.onmessage = (event) => {
50
80
  if (event.data.type === "ERROR") {
51
81
  if (handle_error)
@@ -55,22 +85,74 @@ export async function createWebworker(handle_result, scan_settings_path, pathPre
55
85
  if (handle_result)
56
86
  handle_result(event.data.payload);
57
87
  }
88
+ else if (event.data.type === "SHUTDOWN_DONE") {
89
+ log("createWebworker", ["SHUTDOWN_DONE received"]);
90
+ shutdownResolve?.();
91
+ shutdownResolve = undefined;
92
+ }
58
93
  };
94
+ // wait for one HEAP_SNAPSHOT_DONE / ERROR from a worker
95
+ const waitHeap = (w, path, timeoutMs = 15000) => new Promise((resolve, reject) => {
96
+ const t = setTimeout(() => {
97
+ w.removeEventListener("message", onMsg);
98
+ reject(new Error("heap snapshot timeout: " + path));
99
+ }, timeoutMs);
100
+ const onMsg = (event) => {
101
+ if (event.data?.type === "HEAP_SNAPSHOT_DONE") {
102
+ clearTimeout(t);
103
+ w.removeEventListener("message", onMsg);
104
+ resolve(event.data.path || path);
105
+ }
106
+ else if (event.data?.type === "HEAP_SNAPSHOT_ERROR") {
107
+ clearTimeout(t);
108
+ w.removeEventListener("message", onMsg);
109
+ reject(new Error(String(event.data.error || "heap snapshot failed")));
110
+ }
111
+ };
112
+ w.addEventListener("message", onMsg);
113
+ try {
114
+ w.postMessage({ type: "heap_snapshot", path });
115
+ }
116
+ catch (err) {
117
+ clearTimeout(t);
118
+ w.removeEventListener("message", onMsg);
119
+ reject(err);
120
+ }
121
+ });
59
122
  return {
60
123
  fetchWorker: coordinationWorker,
61
124
  cpuWorkers,
62
- terminate: () => {
63
- for (const p of cpuPorts) {
64
- p.close();
125
+ // graceful only: abort fetch, cancel cpus, wait. does not terminate.
126
+ shutdown: async (timeoutMs = 5000) => {
127
+ if (shuttingDown)
128
+ return;
129
+ shuttingDown = true;
130
+ log("createWebworker", ["shutdown start, timeoutMs=", timeoutMs]);
131
+ const done = new Promise((resolve) => {
132
+ shutdownResolve = resolve;
133
+ });
134
+ try {
135
+ coordinationWorker.postMessage({ type: "shutdown" });
65
136
  }
66
- coordinationWorker.onmessage = null;
67
- coordinationWorker.onerror = null;
68
- for (const w of cpuWorkers) {
69
- w.onerror = null;
70
- w.onmessage = null;
71
- w.terminate();
137
+ catch (err) {
138
+ log("createWebworker", ["post shutdown failed", String(err)]);
139
+ return;
72
140
  }
73
- coordinationWorker.terminate();
141
+ await Promise.race([done, sleep(timeoutMs)]);
142
+ },
143
+ terminate: () => {
144
+ hardTerminate();
145
+ },
146
+ dumpHeaps: async (dir = ".") => {
147
+ const stamp = Date.now();
148
+ const paths = [];
149
+ const coordPath = `${dir}/coordinator-${stamp}.heapsnapshot`;
150
+ paths.push(await waitHeap(coordinationWorker, coordPath));
151
+ for (let i = 0; i < cpuWorkers.length; i++) {
152
+ const p = `${dir}/cpubound-${i}-${stamp}.heapsnapshot`;
153
+ paths.push(await waitHeap(cpuWorkers[i], p));
154
+ }
155
+ return paths;
74
156
  },
75
157
  };
76
158
  }
@@ -27,6 +27,10 @@ max_blocks_buffer_size = MAX_BLOCKS_BUFFER_SIZE, anchor_range, stopSync) {
27
27
  };
28
28
  yield connection_status.last_packet;
29
29
  while (true) {
30
+ if (stopSync?.aborted) {
31
+ log("blocksBufferFetchLoop", ["aborted, exiting fetch loop"]);
32
+ return;
33
+ }
30
34
  // avoid overwriting last_packet in case of catastrophic reorg
31
35
  // this generator will block on this. coordinator should rethrow the CatastrophicReorgError
32
36
  if (connection_status.last_packet.status === "catastrophic_reorg") {
@@ -39,9 +43,17 @@ max_blocks_buffer_size = MAX_BLOCKS_BUFFER_SIZE, anchor_range, stopSync) {
39
43
  bytes_read: 0,
40
44
  node_url: node_url,
41
45
  timestamp: new Date().toISOString(),
46
+ daemon_height: connection_status.sync.daemon_height ||
47
+ connection_status.last_packet.daemon_height,
42
48
  };
43
49
  yield connection_status.last_packet;
44
- await sleep(5000);
50
+ try {
51
+ await sleep(1000, stopSync);
52
+ }
53
+ catch {
54
+ log("blocksBufferFetchLoop", ["aborted during buffer-full sleep"]);
55
+ return;
56
+ }
45
57
  continue;
46
58
  }
47
59
  log("blocksBufferFetchLoop", [
@@ -56,11 +68,18 @@ max_blocks_buffer_size = MAX_BLOCKS_BUFFER_SIZE, anchor_range, stopSync) {
56
68
  get_blocks_bin = await doRPCrequest(nodeUrl, current_range, stopSync);
57
69
  }
58
70
  catch (error) {
71
+ // abort is intentional shutdown, not a connection failure
72
+ if (stopSync?.aborted || isAbortError(error)) {
73
+ log("blocksBufferFetchLoop", ["aborted during rpc request"]);
74
+ return;
75
+ }
59
76
  connection_status.last_packet = {
60
77
  status: "connection_failed",
61
78
  bytes_read: 0,
62
79
  node_url,
63
80
  timestamp: new Date().toISOString(),
81
+ daemon_height: connection_status.sync.daemon_height ||
82
+ connection_status.last_packet.daemon_height,
64
83
  };
65
84
  yield connection_status.last_packet;
66
85
  throw error;
@@ -78,7 +97,13 @@ max_blocks_buffer_size = MAX_BLOCKS_BUFFER_SIZE, anchor_range, stopSync) {
78
97
  yield connection_status.last_packet;
79
98
  // no new blocks: at tip, sleep and retry
80
99
  if (!result_meta.block_infos.length) {
81
- await sleep(1000);
100
+ try {
101
+ await sleep(1000, stopSync);
102
+ }
103
+ catch {
104
+ log("blocksBufferFetchLoop", ["aborted during tip sleep"]);
105
+ return;
106
+ }
82
107
  continue;
83
108
  }
84
109
  const bufferItem = makeBlocksBufferItem(result_meta, get_blocks_bin);
@@ -379,3 +404,9 @@ export async function reduceStartHeightToTip(start_height, node_url) {
379
404
  export class CatastrophicReorgError extends Error {
380
405
  name = "CatastrophicReorgError";
381
406
  }
407
+ function isAbortError(error) {
408
+ if (!error || typeof error !== "object")
409
+ return false;
410
+ const name = error.name;
411
+ return name === "AbortError" || name === "DOMException";
412
+ }
@@ -19,6 +19,12 @@ export type ConnectionStatus = {
19
19
  last_packet: ConnectionSatusLastPacket;
20
20
  sync: ConnectionStatusSync;
21
21
  };
22
+ export declare function applyWalletScanProgress(cs: ConnectionStatus, progress: {
23
+ current_scan_height: number;
24
+ scanned_ranges?: CacheRange[];
25
+ daemon_height?: number;
26
+ eta?: string;
27
+ }): void;
22
28
  export declare const DEFAULT_CONNECTION_STATUS_PREFIX = "ConnectionStatus-";
23
29
  export declare function msToHHMM(ms: number): string;
24
30
  export declare function emptyConnectionStatus(overrides?: Partial<ConnectionStatus>): ConnectionStatus;
@@ -1,5 +1,20 @@
1
1
  import { atomicWrite, } from "../api";
2
2
  import { SCAN_SETTINGS_STORE_NAME_DEFAULT } from "../api";
3
+ // wallet progress write: always set scan fields; only replace eta when a new one is provided
4
+ // so a missing eta does not wipe / flicker the previous value.
5
+ export function applyWalletScanProgress(cs, progress) {
6
+ cs.sync.current_scan_height = progress.current_scan_height;
7
+ if (progress.scanned_ranges) {
8
+ cs.sync.scanned_ranges = progress.scanned_ranges;
9
+ }
10
+ if (typeof progress.daemon_height === "number") {
11
+ cs.sync.daemon_height = progress.daemon_height;
12
+ }
13
+ if (progress.eta !== undefined) {
14
+ cs.sync.eta = progress.eta;
15
+ }
16
+ cs.sync.timestamp = new Date().toISOString();
17
+ }
3
18
  export const DEFAULT_CONNECTION_STATUS_PREFIX = "ConnectionStatus-";
4
19
  export function msToHHMM(ms) {
5
20
  const totalSeconds = Math.floor(ms / 1000);
@@ -4,6 +4,7 @@ export declare class ConnectionStatusOpened {
4
4
  private _timer;
5
5
  private _cached;
6
6
  private _prevConnected;
7
+ private _prevSyncKey;
7
8
  private _path;
8
9
  constructor(scan_settings_path: string, _onChange?: ((status: ConnectionStatus | null) => void) | null | undefined);
9
10
  watch(intervalMs?: number): Promise<void>;
@@ -11,5 +12,6 @@ export declare class ConnectionStatusOpened {
11
12
  get connectionStatus(): ConnectionStatus | null;
12
13
  get isConnected(): boolean;
13
14
  get daemonHeight(): number | undefined;
15
+ private _syncKey;
14
16
  private _poll;
15
17
  }
@@ -4,6 +4,8 @@ export class ConnectionStatusOpened {
4
4
  _timer;
5
5
  _cached = null;
6
6
  _prevConnected = false;
7
+ // notify when sync progress fields change, not only connect flip
8
+ _prevSyncKey = "";
7
9
  _path;
8
10
  constructor(scan_settings_path, _onChange) {
9
11
  this._onChange = _onChange;
@@ -37,14 +39,29 @@ export class ConnectionStatusOpened {
37
39
  return age >= 0 && age <= 10_000;
38
40
  }
39
41
  get daemonHeight() {
40
- return this._cached?.last_packet?.daemon_height;
42
+ return this._cached?.sync?.daemon_height;
43
+ }
44
+ _syncKey(cs) {
45
+ const s = cs?.sync;
46
+ if (!s)
47
+ return "";
48
+ return [
49
+ s.daemon_height ?? "",
50
+ s.current_scan_height ?? "",
51
+ s.eta ?? "",
52
+ s.timestamp ?? "",
53
+ ].join("|");
41
54
  }
42
55
  async _poll() {
43
56
  this._cached =
44
57
  (await readConnectionStatusFile(this._path).catch(() => null)) || null;
45
58
  const now = this.isConnected;
46
- if (now !== this._prevConnected) {
47
- this._prevConnected = now;
59
+ const syncKey = this._syncKey(this._cached);
60
+ const connectedChanged = now !== this._prevConnected;
61
+ const syncChanged = syncKey !== this._prevSyncKey;
62
+ this._prevConnected = now;
63
+ this._prevSyncKey = syncKey;
64
+ if (connectedChanged || syncChanged) {
48
65
  this._onChange?.(this._cached);
49
66
  }
50
67
  }
@@ -7,9 +7,12 @@ import { ManyScanCachesOpened, ScanCacheOpened, type ManyScanCachesOpenedCreateO
7
7
  * @param options.no_worker to feed the ManyScanCachesOpened manually with .feed(params) from CacheChangedCallbackParams
8
8
  * @param options.no_stats to disable creation of stats file with aggregated information e.g. amount per wallet / subaddress
9
9
  * @param options.notifyMasterChanged pass the output of this to another (no_worker) instance to feed
10
+ * @param options.logs "console" | "file" | "console-and-file" | "off"
11
+ * @param options.logs_include function names to include (e.g. handleCpuboundScan, blocksBufferFetchLoop)
12
+ * @param options.logs_exclude function names to exclude
10
13
  * @returns Promise<ManyScanCachesOpened>
11
14
  */
12
- export declare function openWallets(options?: ManyScanCachesOpenedCreateOptions): Promise<ManyScanCachesOpened | undefined>;
15
+ export declare function openWallets(options?: ManyScanCachesOpenedCreateOptions): Promise<ManyScanCachesOpened>;
13
16
  /**
14
17
  * Opens a **single wallet** for scanning.
15
18
  * **Touches ScanSettings.json**,
@@ -8,6 +8,9 @@ import { openScanSettingsFile, writeWalletToScanSettings } from "../api";
8
8
  * @param options.no_worker to feed the ManyScanCachesOpened manually with .feed(params) from CacheChangedCallbackParams
9
9
  * @param options.no_stats to disable creation of stats file with aggregated information e.g. amount per wallet / subaddress
10
10
  * @param options.notifyMasterChanged pass the output of this to another (no_worker) instance to feed
11
+ * @param options.logs "console" | "file" | "console-and-file" | "off"
12
+ * @param options.logs_include function names to include (e.g. handleCpuboundScan, blocksBufferFetchLoop)
13
+ * @param options.logs_exclude function names to exclude
11
14
  * @returns Promise<ManyScanCachesOpened>
12
15
  */
13
16
  export async function openWallets(options) {
@@ -129,7 +129,8 @@ export declare class ScanCacheOpened {
129
129
  remove: () => null;
130
130
  };
131
131
  pause(): Promise<void>;
132
- stopWorker(): void;
132
+ dumpWorkerHeaps(dir?: string): Promise<string[]>;
133
+ stopWorker(): Promise<void>;
133
134
  unpause(): Promise<void>;
134
135
  private _onWorkerError;
135
136
  /**
@@ -178,6 +179,9 @@ export type ManyScanCachesOpenedCreateOptions = {
178
179
  retryDelayMs?: number;
179
180
  connectionStatusIntervalMs?: number;
180
181
  onConnectionStatusChange?: ((status: ConnectionStatus | null) => void) | null;
182
+ logs?: LogSetting;
183
+ logs_include?: PossibleLogs[];
184
+ logs_exclude?: PossibleLogs[];
181
185
  };
182
186
  export declare class ManyScanCachesOpened {
183
187
  readonly connectionStatusOpened: ConnectionStatusOpened;
@@ -187,8 +191,9 @@ export declare class ManyScanCachesOpened {
187
191
  get current_height(): number | null;
188
192
  get node_url(): string;
189
193
  changeNodeUrlAndStartHeight(node_url?: string, start_height?: number | null): Promise<void>;
190
- retry(): Promise<boolean>;
191
- stopWorker(): void;
194
+ retry(): Promise<boolean | undefined>;
195
+ stopWorker(): Promise<void>;
196
+ dumpWorkerHeaps(dir?: string): Promise<string[]>;
192
197
  changeNodeUrl(node_url: string): Promise<void>;
193
198
  get merchant_confirmations(): number | null | undefined;
194
199
  get cpu_worker_count(): number | undefined;
@@ -206,8 +211,9 @@ export declare class ManyScanCachesOpened {
206
211
  setWalletSlot(primary_address: string, slot?: number): Promise<void>;
207
212
  changeStartHeight(start_height: number | null): Promise<void>;
208
213
  private static _buildWallets;
209
- static create(options: ManyScanCachesOpenedCreateOptions): Promise<ManyScanCachesOpened | undefined>;
214
+ static create(options: ManyScanCachesOpenedCreateOptions): Promise<ManyScanCachesOpened>;
210
215
  buildWallets(): Promise<void>;
216
+ private reloadWalletsAfterStop;
211
217
  addViewWallet(primary_address: string, view_key: string, fields?: {
212
218
  wallet_name?: string;
213
219
  wallet_slot?: number;