@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.
@@ -37,11 +37,13 @@ export class ScanCacheOpened {
37
37
  /** sample sizes to try when decoyRetry is enabled, in order */
38
38
  decoyRetrySizes = [20, 50, 100, 200, 500];
39
39
  static async create(params) {
40
- const theCatchToBeOpened = await readCacheFileDefaultLocation(params.primary_address, params.pathPrefix);
40
+ // same dir as workers / connection status: next to ScanSettings.json
41
+ const pathPrefix = getPathPrefix(params.scan_settings_path, params.pathPrefix);
42
+ const theCatchToBeOpened = await readCacheFileDefaultLocation(params.primary_address, pathPrefix);
41
43
  if (!params.primary_address)
42
44
  throw new Error(`primary_address is required, potentially half filled out wallet setting in: ${params.scan_settings_path || SCAN_SETTINGS_STORE_NAME_DEFAULT}`);
43
45
  // use ScanSettingsOpened instead of direct scanSettings calls
44
- const scanSettings = await ScanSettingsOpened.create(params.scan_settings_path, params.pathPrefix);
46
+ const scanSettings = await ScanSettingsOpened.create(params.scan_settings_path, pathPrefix);
45
47
  const walletSettings = scanSettings.getWallet(params.primary_address);
46
48
  if (!walletSettings)
47
49
  throw new Error(`wallet not found in settings. did you call openwallet with the right params?
@@ -55,7 +57,7 @@ export class ScanCacheOpened {
55
57
  // read secret_view_key and secret_spend_key from env
56
58
  const walletSettingsWithKeys = await walletSettingsPlusKeys(walletWithSettings);
57
59
  // create viewpair + ScanCacheOpened instance
58
- const scanCacheOpen = new ScanCacheOpened(scanSettings, await ViewPair.create(params.primary_address, walletSettingsWithKeys.secret_view_key, walletSettings.subaddress_index, scanSettings.node_url), walletSettings.wallet_route, walletSettings.wallet_name, walletSettings.wallet_slot, params.no_worker || false, params.masterCacheChanged || null, scanSettings.start_height, params.scan_settings_path, params.pathPrefix, params.workerError);
60
+ const scanCacheOpen = new ScanCacheOpened(scanSettings, await ViewPair.create(params.primary_address, walletSettingsWithKeys.secret_view_key, walletSettings.subaddress_index, scanSettings.node_url), walletSettings.wallet_route, walletSettings.wallet_name, walletSettings.wallet_slot, params.no_worker || false, params.masterCacheChanged || null, scanSettings.start_height, params.scan_settings_path, pathPrefix, params.workerError);
59
61
  if (theCatchToBeOpened)
60
62
  scanCacheOpen._cache = theCatchToBeOpened;
61
63
  if (!walletSettings.halted) {
@@ -66,7 +68,7 @@ export class ScanCacheOpened {
66
68
  scanCacheOpen._highest_subaddress_index =
67
69
  walletSettings.subaddress_index || SUB_ADDRESS_INDEX_DEFAULT_VALUE;
68
70
  if (!params.no_stats) {
69
- scanCacheOpen._stats = await alignScanStatsWithCache(scanCacheOpen._cache, scanCacheOpen.view_pair, params.primary_address, getPathPrefix(params.scan_settings_path, params.pathPrefix), walletSettings.subaddress_index, lastRange(scanCacheOpen._cache.scanned_ranges)?.end);
71
+ scanCacheOpen._stats = await alignScanStatsWithCache(scanCacheOpen._cache, scanCacheOpen.view_pair, params.primary_address, pathPrefix, walletSettings.subaddress_index, lastRange(scanCacheOpen._cache.scanned_ranges)?.end);
70
72
  }
71
73
  else {
72
74
  scanCacheOpen._no_stats = params.no_stats; // true
@@ -83,8 +85,10 @@ export class ScanCacheOpened {
83
85
  return this._highest_subaddress_index;
84
86
  }
85
87
  get current_height() {
86
- let current_range = findRange(this._cache.scanned_ranges, this._start_height || 0);
87
- return current_range?.end || null;
88
+ // progress on the range that covers start_height (start can jump;
89
+ // lastRange would pick a different segment and be wrong)
90
+ const current_range = findRange(this._cache.scanned_ranges, this._start_height || 0);
91
+ return current_range == null ? null : current_range.end;
88
92
  }
89
93
  get current_top_range_height() {
90
94
  if (typeof this._stats === "undefined" || this._stats === null)
@@ -92,7 +96,7 @@ export class ScanCacheOpened {
92
96
  return this._stats.height;
93
97
  }
94
98
  async changeStartHeight(start_height) {
95
- this.stopWorker();
99
+ await this.stopWorker();
96
100
  await this._scanSettings.setStartHeight(start_height);
97
101
  this._start_height = start_height;
98
102
  await this.unpause();
@@ -167,7 +171,7 @@ export class ScanCacheOpened {
167
171
  return this._scanSettings.logs_exclude;
168
172
  }
169
173
  async changeNodeUrlAndStartHeight(node_url, start_height) {
170
- this.stopWorker();
174
+ await this.stopWorker();
171
175
  if (node_url !== undefined) {
172
176
  await this._scanSettings.setNodeUrl(node_url);
173
177
  this.node_url = node_url;
@@ -179,7 +183,7 @@ export class ScanCacheOpened {
179
183
  await this.unpause();
180
184
  }
181
185
  async changeNodeUrl(node_url) {
182
- this.stopWorker();
186
+ await this.stopWorker();
183
187
  await this._scanSettings.setNodeUrl(node_url);
184
188
  this.node_url = node_url;
185
189
  await this.unpause();
@@ -188,7 +192,7 @@ export class ScanCacheOpened {
188
192
  await this._scanSettings.setMerchantConfirmations(merchant_confirmations);
189
193
  }
190
194
  async setCpuWorkerCount(cpu_worker_count) {
191
- this.stopWorker();
195
+ await this.stopWorker();
192
196
  await this._scanSettings.setCpuWorkerCount(cpu_worker_count);
193
197
  await this.unpause();
194
198
  }
@@ -201,7 +205,7 @@ export class ScanCacheOpened {
201
205
  await this._scanSettings.setWalletName(this.view_pair.primary_address, name);
202
206
  }
203
207
  async retry() {
204
- this.stopWorker();
208
+ await this.stopWorker();
205
209
  // scansettings so external changes (e.g. from a sidebar frontend instance) are picked up
206
210
  await this._scanSettings.reload();
207
211
  const walletStillExists = this._scanSettings.walletExists(this.primary_address);
@@ -396,7 +400,7 @@ export class ScanCacheOpened {
396
400
  if (sendResult.status !== "OK")
397
401
  throw new Error("send raw transaction rpc returned error");
398
402
  // before writing the scan cache, we stop the worker to avoid a race
399
- this.stopWorker();
403
+ await this.stopWorker();
400
404
  // write txlog to cache + update pending_spent_utxos (affects stats + spendability)
401
405
  await writeCacheFileDefaultLocationThrows({
402
406
  primary_address: this.primary_address,
@@ -440,7 +444,7 @@ export class ScanCacheOpened {
440
444
  }
441
445
  catch (e) {
442
446
  // before writing the scan cache, we stop the worker to avoid a race
443
- this.stopWorker();
447
+ await this.stopWorker();
444
448
  // write txlog error to cache
445
449
  await writeCacheFileDefaultLocationThrows({
446
450
  primary_address: this.primary_address,
@@ -550,15 +554,35 @@ export class ScanCacheOpened {
550
554
  };
551
555
  }
552
556
  async pause() {
553
- this.stopWorker();
557
+ await this.stopWorker();
554
558
  return await this._scanSettings.haltWallet(this.view_pair.primary_address);
555
559
  }
556
- stopWorker() {
560
+ // debug: dump coordinator + cpu worker heaps while they are live
561
+ // in browser: use inspector directly, this is only useful for bun
562
+ async dumpWorkerHeaps(dir) {
563
+ if (!this.worker?.dumpHeaps)
564
+ return [];
565
+ return await this.worker.dumpHeaps(dir);
566
+ }
567
+ async stopWorker() {
557
568
  if (this.worker) {
558
- this.worker.terminate();
569
+ const worker = this.worker;
570
+ // clear ref first so concurrent stop/retry cannot double shutdown
559
571
  this.worker.cpuWorkers = [];
560
572
  this.worker.fetchWorker = undefined;
561
573
  delete this.worker;
574
+ // drain coordinator first, then hard kill workers
575
+ try {
576
+ await worker.shutdown();
577
+ }
578
+ catch {
579
+ // still terminate below
580
+ }
581
+ try {
582
+ worker.terminate();
583
+ }
584
+ catch {
585
+ }
562
586
  }
563
587
  }
564
588
  async unpause() {
@@ -697,10 +721,17 @@ export class ManyScanCachesOpened {
697
721
  return null;
698
722
  return this.wallets[0]?.start_height;
699
723
  }
724
+ // overall scan tip = lagging non-halted wallet (this.wallets is already non-halted)
700
725
  get current_height() {
701
- if (this.wallets.length === 0)
702
- return null;
703
- return this.wallets[0]?.current_height;
726
+ let min = null;
727
+ for (const w of this.wallets) {
728
+ const h = w.current_height;
729
+ if (h == null)
730
+ continue;
731
+ if (min == null || h < min)
732
+ min = h;
733
+ }
734
+ return min;
704
735
  }
705
736
  get node_url() {
706
737
  if (this.wallets.length === 0)
@@ -709,25 +740,32 @@ export class ManyScanCachesOpened {
709
740
  }
710
741
  async changeNodeUrlAndStartHeight(node_url, start_height) {
711
742
  if (this.wallets.length === 0)
712
- throw new Error("no wallets");
743
+ return;
713
744
  const masterWallet = this.wallets[0];
714
745
  return await masterWallet.changeNodeUrlAndStartHeight(node_url, start_height);
715
746
  }
716
747
  async retry() {
717
748
  if (this.wallets.length === 0)
718
- throw new Error("no wallets");
749
+ return;
719
750
  const masterWallet = this.wallets[0];
720
751
  return await masterWallet.retry();
721
752
  }
722
- stopWorker() {
753
+ async stopWorker() {
723
754
  if (this.wallets.length === 0)
724
- throw new Error("no wallets");
755
+ return;
725
756
  const masterWallet = this.wallets[0];
726
- return masterWallet.stopWorker();
757
+ return await masterWallet.stopWorker();
758
+ }
759
+ // debug: dump live coordinator + cpu worker heaps via master wallet workers
760
+ // in browser: use inspector directly, this is only useful for bun
761
+ async dumpWorkerHeaps(dir) {
762
+ if (this.wallets.length === 0)
763
+ return [];
764
+ return await this.wallets[0].dumpWorkerHeaps(dir);
727
765
  }
728
766
  async changeNodeUrl(node_url) {
729
767
  if (this.wallets.length === 0)
730
- throw new Error("no wallets");
768
+ return;
731
769
  const masterWallet = this.wallets[0];
732
770
  return await masterWallet.changeNodeUrl(node_url);
733
771
  }
@@ -742,19 +780,13 @@ export class ManyScanCachesOpened {
742
780
  return this.wallets[0]?.cpu_worker_count;
743
781
  }
744
782
  get logs() {
745
- if (this.wallets.length === 0)
746
- return undefined;
747
- return this.wallets[0]?.logs;
783
+ return this._scanSettings.logs;
748
784
  }
749
785
  get logs_include() {
750
- if (this.wallets.length === 0)
751
- return undefined;
752
- return this.wallets[0]?.logs_include;
786
+ return this._scanSettings.logs_include;
753
787
  }
754
788
  get logs_exclude() {
755
- if (this.wallets.length === 0)
756
- return undefined;
757
- return this.wallets[0]?.logs_exclude;
789
+ return this._scanSettings.logs_exclude;
758
790
  }
759
791
  get connectionStatus() {
760
792
  return this.connectionStatusOpened.connectionStatus;
@@ -770,18 +802,16 @@ export class ManyScanCachesOpened {
770
802
  }
771
803
  async setMerchantConfirmations(merchant_confirmations) {
772
804
  if (this.wallets.length === 0)
773
- throw new Error("no wallets");
805
+ return;
774
806
  return await this.wallets[0].setMerchantConfirmations(merchant_confirmations);
775
807
  }
776
808
  async setCpuWorkerCount(cpu_worker_count) {
777
809
  if (this.wallets.length === 0)
778
- throw new Error("no wallets");
810
+ return;
779
811
  return await this.wallets[0].setCpuWorkerCount(cpu_worker_count);
780
812
  }
781
813
  async setLogSettings(logs, logs_include, logs_exclude) {
782
- if (this.wallets.length === 0)
783
- throw new Error("no wallets");
784
- return await this.wallets[0].setLogSettings(logs, logs_include, logs_exclude);
814
+ return await this._scanSettings.setLogSettings(logs, logs_include, logs_exclude);
785
815
  }
786
816
  async setWalletName(primary_address, name) {
787
817
  await this._scanSettings.setWalletName(primary_address, name);
@@ -791,7 +821,7 @@ export class ManyScanCachesOpened {
791
821
  }
792
822
  async changeStartHeight(start_height) {
793
823
  if (this.wallets.length === 0)
794
- throw new Error("no wallets");
824
+ return;
795
825
  const masterWallet = this.wallets[0];
796
826
  return await masterWallet.changeStartHeight(start_height);
797
827
  }
@@ -851,15 +881,34 @@ export class ManyScanCachesOpened {
851
881
  static async create(options) {
852
882
  const { scan_settings_path, pathPrefix, onConnectionStatusChange, connectionStatusIntervalMs, autoRetry, retryDelayMs, } = options;
853
883
  const scanSettingsOpened = await ScanSettingsOpened.create(scan_settings_path, pathPrefix);
854
- if (!scanSettingsOpened.wallets || scanSettingsOpened.wallets.length === 0)
855
- throw new Error(`no wallets in settings file. Did you supply the right path?
856
- are there wallets in the default '${SCAN_SETTINGS_STORE_NAME_DEFAULT}' file?`);
884
+ // persist log options into ScanSettings.json before workers start
885
+ if (options.logs !== undefined ||
886
+ options.logs_include !== undefined ||
887
+ options.logs_exclude !== undefined) {
888
+ await scanSettingsOpened.setLogSettings(options.logs !== undefined ? options.logs : scanSettingsOpened.logs, options.logs_include !== undefined
889
+ ? options.logs_include
890
+ : scanSettingsOpened.logs_include, options.logs_exclude !== undefined
891
+ ? options.logs_exclude
892
+ : scanSettingsOpened.logs_exclude);
893
+ }
857
894
  // wrap workerError with auto-retry
858
895
  let retryTimer;
859
896
  let instance = null;
860
897
  const retryFn = async () => {
861
- await instance?.buildWallets();
862
- const node_url = instance?.node_url;
898
+ // no wallets means idle, do not rebuild or restart workers
899
+ if (!instance || instance.wallets.length === 0) {
900
+ clearTimeout(retryTimer);
901
+ retryTimer = undefined;
902
+ return;
903
+ }
904
+ await instance.buildWallets();
905
+ // still empty after rebuild, stay idle
906
+ if (instance.wallets.length === 0) {
907
+ clearTimeout(retryTimer);
908
+ retryTimer = undefined;
909
+ return;
910
+ }
911
+ const node_url = instance.node_url;
863
912
  if (!node_url)
864
913
  throw new Error("No nodeurl set, can't retry connection");
865
914
  const getinfo_result = await get_info(node_url)
@@ -893,14 +942,13 @@ export class ManyScanCachesOpened {
893
942
  console.error("unable to connect to node ... retrying ...");
894
943
  }
895
944
  }
896
- if (!retryTimer) {
945
+ // only schedule retry when we still have wallets to scan
946
+ if (!retryTimer && instance && instance.wallets.length > 0) {
897
947
  retryTimer = setTimeout(retryFn, retryDelayMs ?? 5000);
898
948
  }
899
949
  };
900
950
  }
901
- const wallets = await this._buildWallets(scanSettingsOpened, newOptions);
902
- if (!wallets)
903
- return undefined;
951
+ const wallets = (await this._buildWallets(scanSettingsOpened, newOptions)) ?? [];
904
952
  const csOpened = new ConnectionStatusOpened(scan_settings_path || SCAN_SETTINGS_STORE_NAME_DEFAULT, autoRetry
905
953
  ? (status) => {
906
954
  if (status?.last_packet?.status === "OK" && connectionFailedShown) {
@@ -916,26 +964,39 @@ export class ManyScanCachesOpened {
916
964
  return instance;
917
965
  }
918
966
  async buildWallets() {
919
- this.stopWorker();
967
+ await this.stopWorker();
968
+ await this.reloadWalletsAfterStop();
969
+ }
970
+ // assumes stopWorker already ran (graceful shutdown + terminate)
971
+ async reloadWalletsAfterStop() {
920
972
  await this._scanSettings.reload();
973
+ // empty after reload: stay idle, do not open ScanCacheOpened or start workers
974
+ const remaining = this._scanSettings.wallets.filter((w) => !w?.halted);
975
+ if (remaining.length === 0) {
976
+ this._wallets = [];
977
+ return;
978
+ }
921
979
  const newWallets = await ManyScanCachesOpened._buildWallets(this._scanSettings, this._options);
922
- if (!newWallets)
923
- throw new Error("no non-halted wallets left after rebuild, use addViewWallet or addSpendWallet first");
924
- this._wallets = newWallets;
980
+ this._wallets = newWallets ?? [];
925
981
  }
926
982
  async addViewWallet(primary_address, view_key, fields) {
983
+ await this.stopWorker();
927
984
  await this._scanSettings.addViewWallet(primary_address, view_key, fields);
928
- await this.buildWallets();
985
+ await this.reloadWalletsAfterStop();
929
986
  }
930
987
  async addSpendWallet(wallet_secret, fields) {
988
+ await this.stopWorker();
931
989
  await this._scanSettings.addSpendWallet(wallet_secret, fields);
932
- await this.buildWallets();
990
+ await this.reloadWalletsAfterStop();
933
991
  }
934
992
  async removeWallet(primary_address) {
993
+ await this.stopWorker();
935
994
  await this._scanSettings.removeWallet(primary_address);
936
- await this.buildWallets();
995
+ await this.reloadWalletsAfterStop();
937
996
  }
938
997
  async feed(params) {
998
+ if (this.wallets.length === 0)
999
+ return;
939
1000
  await this.wallets[0].feed(params);
940
1001
  }
941
1002
  _wallets = [];
@@ -64,7 +64,6 @@ export type CoordinatorEvent = {
64
64
  } | {
65
65
  type: "scan_ready";
66
66
  address: string;
67
- result: ScanLoopYield;
68
67
  newCache: ScanCache;
69
68
  changed_outputs: {
70
69
  output: any;
@@ -72,11 +71,13 @@ export type CoordinatorEvent = {
72
71
  }[];
73
72
  } | {
74
73
  type: "all_idle";
74
+ } | {
75
+ type: "shutdown_done";
75
76
  } | {
76
77
  type: "error";
77
78
  error: Error;
78
79
  };
79
- export declare function setupCoordinator(scanSettingsPath?: string, pathPrefix?: string): Promise<false | {
80
+ export declare function setupCoordinator(scanSettingsPath?: string, pathPrefix?: string, stopSync?: AbortSignal): Promise<false | {
80
81
  blocksGenerator: AsyncGenerator<BlocksBufferLoopResult, any, any>;
81
82
  workBuffer: WorkItem[];
82
83
  blocksBuffer: GetBlocksBinBufferItem[];
@@ -96,10 +97,12 @@ export type BlocksBufferRacer = {
96
97
  };
97
98
  export type ScanLoopRacer = ScanLoopYield;
98
99
  export type Racers = BlocksBufferRacer | ScanLoopRacer;
100
+ export declare function resetInProgressWorkItems(workBuffer: WorkItem[]): void;
101
+ export declare function cancelBusyCpuPorts(ports: PortStatus[], timeoutMs?: number): Promise<void>;
99
102
  /**
100
103
  * coordinator main (multithreaded): dispatches scan work to CPU workers
101
104
  * via MessagePorts, processes results in order per wallet.
102
105
  * falls back to single-threaded coordinator if no cpuPorts provided.
103
106
  */
104
- export declare function coordinatorMainMultithreaded(scanSettingsPath?: string, pathPrefix?: string, cpuPorts?: MessagePort[]): AsyncGenerator<CoordinatorEvent>;
107
+ export declare function coordinatorMainMultithreaded(scanSettingsPath?: string, pathPrefix?: string, cpuPorts?: MessagePort[], stopSync?: AbortSignal): AsyncGenerator<CoordinatorEvent>;
105
108
  export declare function scheduleWorkOnCpuPorts(ports: PortStatus[], work_buffer: WorkItem[]): Promise<void>;
@@ -1,5 +1,5 @@
1
- import { setupBlocksBufferGenerator, ViewPair, handleConnectionStatusChanges, processScanResult, writeCacheToFile, sleep, findTipIndex, readWriteConnectionStatusFile, } from "../../api";
2
- import { markWorkItemAsDone, } from "./scanLoop";
1
+ import { setupBlocksBufferGenerator, ViewPair, handleConnectionStatusChanges, processScanResult, writeCacheToFile, sleep, findTipIndex, readWriteConnectionStatusFile, applyWalletScanProgress, } from "../../api";
2
+ import {} from "./scanLoop";
3
3
  import { makeWorkItem } from "./scanLoop";
4
4
  import { cullTooLargeScanHeight, getNonHaltedWallets, getPathPrefix, openScanSettingsFile, SCAN_SETTINGS_STORE_NAME_DEFAULT, walletSettingsPlusKeys, } from "../../api";
5
5
  import { findRange, initScanCacheFile, lastRange, makeCacheRangeForHeight, mergeRanges, } from "./scanCache";
@@ -220,6 +220,9 @@ export async function processWorkItem(item, workBuffer, blocksBuffer, pathPrefix
220
220
  throw error;
221
221
  }
222
222
  await writeCacheToFile(cache, pathPrefix);
223
+ // raw cpu ScanResult is fully merged into cache now. drop it so workBuffer
224
+ // does not pin all_key_images / hash strings until left-edge free.
225
+ item.result = undefined;
223
226
  item.status = "process_result_done";
224
227
  log("processWorkItem", `process_result_done work_uuid=${item?.work_uuid}`);
225
228
  // remove from blocksbuffer if no more work items reference it
@@ -271,7 +274,7 @@ function computeETA(cache, totalBlocksScanned, scanStartTime) {
271
274
  const etaMs = remaining / blocksPerMs;
272
275
  return msToHHMM(etaMs);
273
276
  }
274
- export async function setupCoordinator(scanSettingsPath, pathPrefix) {
277
+ export async function setupCoordinator(scanSettingsPath, pathPrefix, stopSync) {
275
278
  const work_to_be_done = await findWorkToBeDone(scanSettingsPath, pathPrefix);
276
279
  if (!work_to_be_done)
277
280
  return false;
@@ -280,6 +283,7 @@ export async function setupCoordinator(scanSettingsPath, pathPrefix) {
280
283
  startHeight: work_to_be_done.start_height,
281
284
  anchor_range: work_to_be_done.anchor_range,
282
285
  scanSettingsPath,
286
+ stopSync,
283
287
  });
284
288
  const workBuffer = [];
285
289
  //TODO: refactor to per wallet buffer
@@ -298,16 +302,46 @@ export async function setupCoordinator(scanSettingsPath, pathPrefix) {
298
302
  work_to_be_done,
299
303
  };
300
304
  }
305
+ // flip inflight work back to fresh so a restart can reschedule it
306
+ export function resetInProgressWorkItems(workBuffer) {
307
+ for (const item of workBuffer) {
308
+ if (item.status === "scanwork_in_progress") {
309
+ item.status = "fresh";
310
+ item.result = undefined;
311
+ }
312
+ }
313
+ }
314
+ // tell busy cpu ports to cancel and wait for their promises (with timeout)
315
+ export async function cancelBusyCpuPorts(ports, timeoutMs = 3000) {
316
+ const busy = ports.filter((ps) => ps.promise);
317
+ for (const ps of busy) {
318
+ try {
319
+ sendToCpuWorker(ps.port, "cancel");
320
+ }
321
+ catch (err) {
322
+ log("cancelBusyCpuPorts", ["send cancel failed", String(err)]);
323
+ }
324
+ }
325
+ if (busy.length === 0)
326
+ return;
327
+ await Promise.race([
328
+ Promise.allSettled(busy.map((ps) => ps.promise)),
329
+ sleep(timeoutMs),
330
+ ]);
331
+ for (const ps of ports) {
332
+ ps.promise = null;
333
+ }
334
+ }
301
335
  /**
302
336
  * coordinator main (multithreaded): dispatches scan work to CPU workers
303
337
  * via MessagePorts, processes results in order per wallet.
304
338
  * falls back to single-threaded coordinator if no cpuPorts provided.
305
339
  */
306
- export async function* coordinatorMainMultithreaded(scanSettingsPath, pathPrefix, cpuPorts) {
340
+ export async function* coordinatorMainMultithreaded(scanSettingsPath, pathPrefix, cpuPorts, stopSync) {
307
341
  if (!cpuPorts || cpuPorts.length === 0) {
308
342
  throw new Error("[coordinatorMain Multithreaded] cpuPorts empty, there must be at least cpu worker");
309
343
  }
310
- const ctx = await setupCoordinator(scanSettingsPath, pathPrefix);
344
+ const ctx = await setupCoordinator(scanSettingsPath, pathPrefix, stopSync);
311
345
  if (!ctx)
312
346
  throw new Error("[coordinatorMain Multithreaded] findWorkToBeDone returned false");
313
347
  const work_to_be_done = ctx.work_to_be_done;
@@ -327,6 +361,13 @@ export async function* coordinatorMainMultithreaded(scanSettingsPath, pathPrefix
327
361
  }
328
362
  let race_count = 0;
329
363
  while (true) {
364
+ if (stopSync?.aborted) {
365
+ log("coordinatorMainMultithreaded", ["shutdown signal, draining"]);
366
+ await cancelBusyCpuPorts(freePorts);
367
+ resetInProgressWorkItems(workBuffer);
368
+ yield { type: "shutdown_done" };
369
+ return;
370
+ }
330
371
  race_count++;
331
372
  log("coordinatorMainMultithreaded", ["race_count", race_count]);
332
373
  await scheduleWorkOnCpuPorts(freePorts, workBuffer);
@@ -344,6 +385,16 @@ export async function* coordinatorMainMultithreaded(scanSettingsPath, pathPrefix
344
385
  const winner = await Promise.race(races);
345
386
  log("coordinatorMainMultithreaded", "cpu ports status");
346
387
  if ("src" in winner && winner.src === "blocks") {
388
+ // fetch loop returned (done) on abort, treat as shutdown
389
+ if (winner.value.done) {
390
+ log("coordinatorMainMultithreaded", [
391
+ "blocks generator done, draining",
392
+ ]);
393
+ await cancelBusyCpuPorts(freePorts);
394
+ resetInProgressWorkItems(workBuffer);
395
+ yield { type: "shutdown_done" };
396
+ return;
397
+ }
347
398
  const result = winner.value.value;
348
399
  const { isBlocksBufferChanged } = await handleBlocksYield(result, scanSettingsPath);
349
400
  if (isBlocksBufferChanged) {
@@ -376,30 +427,22 @@ export async function* coordinatorMainMultithreaded(scanSettingsPath, pathPrefix
376
427
  continue;
377
428
  const blockCount = to_be_processed.to - to_be_processed.from + 1;
378
429
  totalBlocksScanned += blockCount;
379
- const eta = computeETA(wallet.cache, totalBlocksScanned, scanStartTime);
380
- // write connection status sync (includes eta)
381
- if (eta) {
382
- await readWriteConnectionStatusFile((cs) => {
383
- cs.sync = {
384
- ...cs.sync,
385
- scanned_ranges: wallet.cache.scanned_ranges,
386
- daemon_height: wallet.cache.daemon_height,
387
- current_scan_height: lastRange(wallet.cache.scanned_ranges)?.end || 0,
388
- eta,
389
- timestamp: new Date().toISOString(),
390
- };
391
- }, scanSettingsPath);
392
- }
393
430
  const res = await processWorkItem(to_be_processed, workBuffer, blocksBuffer, getPathPrefix(scanSettingsPath, pathPrefix), wallet.secret_spend_key);
431
+ // always persist wallet progress after process; eta only if we have a new one
432
+ // so missing eta does not wipe the previous value (no flicker)
433
+ const eta = computeETA(wallet.cache, totalBlocksScanned, scanStartTime);
434
+ await readWriteConnectionStatusFile((cs) => {
435
+ applyWalletScanProgress(cs, {
436
+ current_scan_height: lastRange(wallet.cache.scanned_ranges)?.end || 0,
437
+ scanned_ranges: wallet.cache.scanned_ranges,
438
+ daemon_height: wallet.cache.daemon_height,
439
+ eta,
440
+ });
441
+ }, scanSettingsPath);
394
442
  // log("coordinatorMainMultithreaded", "processWorkItem result");
395
443
  yield {
396
444
  type: "scan_ready",
397
445
  address: wallet.primary_address,
398
- result: {
399
- type: "Ready",
400
- work_uuid: to_be_processed.work_uuid,
401
- result: to_be_processed.result,
402
- },
403
446
  newCache: wallet.cache,
404
447
  changed_outputs: res.changed_outputs,
405
448
  };
@@ -424,6 +467,17 @@ export async function scheduleWorkOnCpuPorts(ports, work_buffer) {
424
467
  const msg = event.data;
425
468
  // handle the result here:
426
469
  log("scheduleWorkOnCpuPorts", ["onmessage result", msg]);
470
+ // cancel ack may omit work_uuid when idle, but inflight cancel carries it
471
+ if (msg.type === "Canceled") {
472
+ if (item.status === "scanwork_in_progress") {
473
+ item.status = "fresh";
474
+ item.result = undefined;
475
+ }
476
+ port_status.promise = null;
477
+ // thin resolve: dont put full msg/result on the port promise on cancel
478
+ resolve_port({ type: "Canceled", work_uuid: item.work_uuid });
479
+ return;
480
+ }
427
481
  if (msg.work_uuid !== item.work_uuid) {
428
482
  log("scheduleWorkOnCpuPorts", [
429
483
  "wrong work_uuid in msg msg.work_uuid=",
@@ -441,10 +495,18 @@ export async function scheduleWorkOnCpuPorts(ports, work_buffer) {
441
495
  log("scheduleWorkOnCpuPorts", "wrong status in msg");
442
496
  throw new Error("[scheduleWorkOnCpuPorts] wrong status in msg");
443
497
  }
498
+ // fat ScanResult lives only on the work item until process clears it
444
499
  item.result = msg.result;
445
500
  item.status = "scanwork_done";
501
+ // cpu already has its own buffer copy; process only needs meta.
502
+ // drop work-item bytes now; blocksBuffer still owns the batch until left-edge free.
503
+ item.batch = {
504
+ ...item.batch,
505
+ data: new Uint8Array(0),
506
+ };
446
507
  port_status.promise = null;
447
- resolve_port(msg);
508
+ // thin resolve: race/await must not retain all_key_images via promise value
509
+ resolve_port({ type: msg.type, work_uuid: msg.work_uuid });
448
510
  };
449
511
  port_status.port.onmessage = onmessage;
450
512
  item.status = "scanwork_in_progress";
@@ -12,9 +12,8 @@ export declare function makeWorkItem(walletConfig: WalletConfigPlusCache, batch:
12
12
  export type ScanLoopIteratorResult = IteratorYieldResult<ScanLoopYield>;
13
13
  export type ScanLoopInput = WorkItem | "cancel" | undefined;
14
14
  export type ScanLoopYield = {
15
- type: "Ready" | "InProgress";
15
+ type: "Ready" | "InProgress" | "Canceled";
16
16
  work_uuid?: string;
17
17
  result?: ScanResult;
18
18
  };
19
19
  export declare function scanLoop(wallet: WalletConfig): AsyncGenerator<ScanLoopYield, void, ScanLoopInput>;
20
- export declare function markWorkItemAsDone(loop_event: ScanLoopYield, work_buffer: WorkItem[]): Promise<WorkItem | undefined>;
@@ -101,16 +101,3 @@ export async function* scanLoop(wallet) {
101
101
  // will be done by processScanResult in the consumer
102
102
  }
103
103
  }
104
- //theoretically markWorkItemAsDone could be done in the generator as WorkItem is passed in by reference
105
- // but: eventually this has to be done accross CPU worker boundaries
106
- // so we act on the yielded scan result
107
- export async function markWorkItemAsDone(loop_event, work_buffer) {
108
- if (loop_event.type === "Ready" && loop_event.work_uuid) {
109
- const work_item = work_buffer.find((w) => w.work_uuid === loop_event.work_uuid);
110
- if (work_item) {
111
- work_item.result = loop_event.result;
112
- work_item.status = "scanwork_done";
113
- return work_item;
114
- }
115
- }
116
- }