@attocash/cli 0.1.1 → 0.2.0
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 +41 -2
- package/dist/application/app.d.ts +11 -0
- package/dist/application/app.js +37 -12
- package/dist/cli/cli.js +13 -2
- package/dist/cli/onboarding.d.ts +1 -2
- package/dist/cli/onboarding.js +2 -2
- package/dist/cli/output.js +1 -1
- package/dist/spending/payments.js +5 -6
- package/dist/storage/state.d.ts +7 -0
- package/dist/storage/state.js +61 -8
- package/dist/wallet/background-receive.d.ts +30 -0
- package/dist/wallet/background-receive.js +117 -0
- package/dist/wallet/receive-daemon.d.ts +1 -0
- package/dist/wallet/receive-daemon.js +63 -0
- package/dist/wallet/work-daemon.d.ts +1 -0
- package/dist/wallet/work-daemon.js +20 -0
- package/dist/wallet/work.d.ts +31 -9
- package/dist/wallet/work.js +307 -78
- package/dist/watches/manager.d.ts +3 -3
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
process.env.KOTLIN_LOGGING_STARTUP_MESSAGE = 'false';
|
|
2
|
+
const [directory, token] = process.argv.slice(2);
|
|
3
|
+
if (directory && token) {
|
|
4
|
+
const { setTimeout: delay } = await import('node:timers/promises');
|
|
5
|
+
const { StateStore } = await import('../storage/state.js');
|
|
6
|
+
const { AttoApplication } = await import('../application/app.js');
|
|
7
|
+
const { BackgroundReceiver, requireReceivingProfile } = await import('./background-receive.js');
|
|
8
|
+
const { errorResult } = await import('../domain/errors.js');
|
|
9
|
+
let store;
|
|
10
|
+
let application;
|
|
11
|
+
let receiver;
|
|
12
|
+
let release;
|
|
13
|
+
const stop = () => receiver?.update(token, { desired: false, state: 'stopping' });
|
|
14
|
+
try {
|
|
15
|
+
store = new StateStore(directory);
|
|
16
|
+
receiver = new BackgroundReceiver(store);
|
|
17
|
+
release = receiver.claim(token);
|
|
18
|
+
if (release) {
|
|
19
|
+
requireReceivingProfile(store);
|
|
20
|
+
const owner = receiver;
|
|
21
|
+
application = new AttoApplication({
|
|
22
|
+
directory, receivingAllowed: () => !owner.stopping(token),
|
|
23
|
+
onReceiveProgress: event => owner.progress(token, event),
|
|
24
|
+
});
|
|
25
|
+
process.once('SIGINT', stop);
|
|
26
|
+
process.once('SIGTERM', stop);
|
|
27
|
+
receiver.update(token, { state: 'running' });
|
|
28
|
+
// Acknowledge local construction/ownership before starting any network or
|
|
29
|
+
// password-store operation. IPC is detached immediately after the reply.
|
|
30
|
+
if (process.connected)
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
process.send({ ready: token }, error => error ? reject(error) : resolve());
|
|
33
|
+
});
|
|
34
|
+
if (process.connected)
|
|
35
|
+
process.disconnect();
|
|
36
|
+
if (!receiver.stopping(token)) {
|
|
37
|
+
void application.start().catch(error => owner.update(token, { lastError: errorResult(error) }));
|
|
38
|
+
while (!receiver.stopping(token))
|
|
39
|
+
await delay(100);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (release)
|
|
45
|
+
receiver?.update(token, { desired: false, state: 'stopping', lastError: errorResult(error) });
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
// Keep ownership and stopping visible until the current receive, startup
|
|
49
|
+
// lookups, and work requests have all finished or been canceled.
|
|
50
|
+
if (release)
|
|
51
|
+
receiver?.update(token, { desired: false, state: 'stopping' });
|
|
52
|
+
await application?.close();
|
|
53
|
+
if (release)
|
|
54
|
+
receiver?.update(token, { desired: false, state: 'stopped' });
|
|
55
|
+
release?.();
|
|
56
|
+
store?.close();
|
|
57
|
+
process.removeListener('SIGINT', stop);
|
|
58
|
+
process.removeListener('SIGTERM', stop);
|
|
59
|
+
if (process.connected)
|
|
60
|
+
process.disconnect();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
process.env.KOTLIN_LOGGING_STARTUP_MESSAGE = 'false';
|
|
2
|
+
const [directory, epoch] = process.argv.slice(2);
|
|
3
|
+
if (directory && epoch) {
|
|
4
|
+
const { StateStore } = await import('../storage/state.js');
|
|
5
|
+
const { WalletWork } = await import('./work.js');
|
|
6
|
+
let store;
|
|
7
|
+
let work;
|
|
8
|
+
try {
|
|
9
|
+
store = new StateStore(directory);
|
|
10
|
+
const state = store;
|
|
11
|
+
work = new WalletWork(state, () => state.get('settings'));
|
|
12
|
+
await work.runDetached(epoch);
|
|
13
|
+
}
|
|
14
|
+
catch { /* Public jobs remain eligible after startup or storage failures. */ }
|
|
15
|
+
finally {
|
|
16
|
+
await work?.close();
|
|
17
|
+
store?.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export {};
|
package/dist/wallet/work.d.ts
CHANGED
|
@@ -5,25 +5,47 @@ export interface BlockWorker {
|
|
|
5
5
|
workBlock(block: AttoBlock, signal?: AbortSignal): Promise<AttoWork>;
|
|
6
6
|
close(): void;
|
|
7
7
|
}
|
|
8
|
-
|
|
8
|
+
export type WorkExecution = 'in-process' | 'detached';
|
|
9
|
+
/** Public preparation, validation and caching. No credential or signer enters
|
|
10
|
+
* this owner. Speculative failures never escape into completed transactions. */
|
|
9
11
|
export declare class WalletWork {
|
|
10
12
|
private readonly store;
|
|
11
13
|
private readonly settings;
|
|
12
|
-
private readonly
|
|
13
|
-
private readonly pending;
|
|
14
|
-
private readonly requests;
|
|
15
|
-
private backgroundCount;
|
|
14
|
+
private readonly execution;
|
|
16
15
|
private closed;
|
|
17
|
-
|
|
16
|
+
private drainTask?;
|
|
17
|
+
private resumeRequested;
|
|
18
|
+
private readonly active;
|
|
19
|
+
private readonly computations;
|
|
20
|
+
private readonly requests;
|
|
21
|
+
constructor(store: StateStore, settings: () => WalletSettings, execution?: WorkExecution);
|
|
22
|
+
private scope;
|
|
23
|
+
private nextBlock;
|
|
24
|
+
private key;
|
|
25
|
+
private head;
|
|
26
|
+
private same;
|
|
18
27
|
isReady(account: AttoAccount): boolean;
|
|
19
28
|
prepare(accounts: readonly AttoAccount[]): void;
|
|
29
|
+
/** A confirmed block is sufficient to prepare its successor, including after
|
|
30
|
+
* publication recovery. This unsigned change template is never published;
|
|
31
|
+
* work depends on the head, height, network and time, not its representative. */
|
|
32
|
+
prepareConfirmed(block: AttoBlock): void;
|
|
33
|
+
private enqueue;
|
|
34
|
+
/** Resume only after successful CLI input validation/operation, never in the constructor. */
|
|
35
|
+
resume(): void;
|
|
20
36
|
worker(): BlockWorker;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
private queue;
|
|
38
|
+
/** Caller owns the short state transaction. Lower observations cannot undo
|
|
39
|
+
* newer heads; an equal-height replacement invalidates the previous fork. */
|
|
40
|
+
private observe;
|
|
24
41
|
private read;
|
|
25
42
|
private save;
|
|
26
43
|
private obtain;
|
|
27
44
|
private compute;
|
|
45
|
+
/** Release the singleton inside the final queue transaction. An enqueue is
|
|
46
|
+
* either seen by this owner or its launch candidate can become the next owner. */
|
|
47
|
+
runDetached(epoch: string): Promise<void>;
|
|
28
48
|
private drain;
|
|
49
|
+
cancel(): Promise<void>;
|
|
50
|
+
close(): Promise<void>;
|
|
29
51
|
}
|
package/dist/wallet/work.js
CHANGED
|
@@ -1,62 +1,153 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { performance } from 'node:perf_hooks';
|
|
6
|
+
import { AttoBlock, AttoChangeBlock, AttoInstant, AttoWork, attoAccountChange, attoBlockWorkTarget, toAttoHeight } from '@attocash/commons-core';
|
|
2
7
|
import { requestWork } from '../network/work.js';
|
|
3
8
|
import { AttoError } from '../domain/errors.js';
|
|
4
|
-
const
|
|
9
|
+
const QUEUE_KEY = 'work.queue';
|
|
10
|
+
const EPOCH_KEY = 'work.epoch';
|
|
5
11
|
const QUEUE_LIMIT = 100;
|
|
6
|
-
|
|
12
|
+
const CONCURRENCY = 2;
|
|
13
|
+
const SPECULATIVE_MS = 10_000;
|
|
14
|
+
const WORKER_MS = 60_000;
|
|
15
|
+
/** Public preparation, validation and caching. No credential or signer enters
|
|
16
|
+
* this owner. Speculative failures never escape into completed transactions. */
|
|
7
17
|
export class WalletWork {
|
|
8
18
|
store;
|
|
9
19
|
settings;
|
|
10
|
-
|
|
11
|
-
pending = new Map();
|
|
12
|
-
requests = new Set();
|
|
13
|
-
backgroundCount = 0;
|
|
20
|
+
execution;
|
|
14
21
|
closed = false;
|
|
15
|
-
|
|
22
|
+
drainTask;
|
|
23
|
+
resumeRequested = false;
|
|
24
|
+
active = new Set();
|
|
25
|
+
computations = new Map();
|
|
26
|
+
requests = new Set();
|
|
27
|
+
constructor(store, settings, execution = 'in-process') {
|
|
16
28
|
this.store = store;
|
|
17
29
|
this.settings = settings;
|
|
30
|
+
this.execution = execution;
|
|
31
|
+
}
|
|
32
|
+
scope() {
|
|
33
|
+
const { network, nodeUrl, workerUrl } = this.settings();
|
|
34
|
+
return JSON.stringify([this.store.get(EPOCH_KEY) ?? null, this.store.get('identity') ?? null, network, nodeUrl, workerUrl]);
|
|
35
|
+
}
|
|
36
|
+
nextBlock(account) {
|
|
37
|
+
return attoAccountChange(account, account.representativeAddress, AttoInstant.Companion.now().toString()).block;
|
|
18
38
|
}
|
|
39
|
+
key(block) { return `work.${block.network.name}.${block.publicKey}`; }
|
|
40
|
+
head(block) { return { scope: this.scope(), target: attoBlockWorkTarget(block), height: block.height.toString() }; }
|
|
41
|
+
same(left, right) { return left.scope === right.scope && left.target === right.target && left.height === right.height; }
|
|
19
42
|
isReady(account) {
|
|
20
|
-
|
|
43
|
+
try {
|
|
44
|
+
return !this.closed && account.network.name === this.settings().network && Boolean(this.read(this.nextBlock(account)));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
21
47
|
return false;
|
|
22
|
-
|
|
48
|
+
}
|
|
23
49
|
}
|
|
24
50
|
prepare(accounts) {
|
|
51
|
+
try {
|
|
52
|
+
this.enqueue(accounts.map(account => this.nextBlock(account)));
|
|
53
|
+
}
|
|
54
|
+
catch { /* Public preparation must not change a completed payment's result. */ }
|
|
55
|
+
}
|
|
56
|
+
/** A confirmed block is sufficient to prepare its successor, including after
|
|
57
|
+
* publication recovery. This unsigned change template is never published;
|
|
58
|
+
* work depends on the head, height, network and time, not its representative. */
|
|
59
|
+
prepareConfirmed(block) {
|
|
60
|
+
try {
|
|
61
|
+
this.enqueue([new AttoChangeBlock(block.network, block.version, block.algorithm, block.publicKey, toAttoHeight((BigInt(block.height.toString()) + 1n).toString()), block.balance, AttoInstant.Companion.now(), block.hash, block.algorithm, block.publicKey)]);
|
|
62
|
+
}
|
|
63
|
+
catch { /* Even template/storage failures are optional after confirmation. */ }
|
|
64
|
+
}
|
|
65
|
+
enqueue(blocks) {
|
|
25
66
|
if (this.closed)
|
|
26
67
|
return;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
68
|
+
try {
|
|
69
|
+
this.store.transaction(() => {
|
|
70
|
+
if (!this.store.get(EPOCH_KEY))
|
|
71
|
+
this.store.set(EPOCH_KEY, randomUUID());
|
|
72
|
+
let queue = this.queue();
|
|
73
|
+
for (const block of blocks) {
|
|
74
|
+
if (block.network.name !== this.settings().network)
|
|
75
|
+
continue;
|
|
76
|
+
const key = this.key(block);
|
|
77
|
+
const head = this.head(block);
|
|
78
|
+
if (!this.observe(key, head))
|
|
79
|
+
continue;
|
|
80
|
+
queue = queue.filter(job => job.scope === head.scope);
|
|
81
|
+
const position = queue.findIndex(job => job.key === key);
|
|
82
|
+
if (this.read(block)) {
|
|
83
|
+
queue = queue.filter(job => job.key !== key || !this.same(job, head));
|
|
84
|
+
}
|
|
85
|
+
else if (position !== -1) {
|
|
86
|
+
if (!this.same(queue[position], head))
|
|
87
|
+
queue[position] = { ...head, key, id: randomUUID(), block: block.toJson() };
|
|
88
|
+
}
|
|
89
|
+
else if (queue.length < QUEUE_LIMIT)
|
|
90
|
+
queue.push({ ...head, key, id: randomUUID(), block: block.toJson() });
|
|
91
|
+
}
|
|
92
|
+
this.store.set(QUEUE_KEY, queue);
|
|
93
|
+
});
|
|
94
|
+
this.resume();
|
|
95
|
+
}
|
|
96
|
+
catch { /* A completed transaction survives speculative storage/launch failures. */ }
|
|
97
|
+
}
|
|
98
|
+
/** Resume only after successful CLI input validation/operation, never in the constructor. */
|
|
99
|
+
resume() {
|
|
100
|
+
if (this.closed)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
if (!this.queue().length)
|
|
104
|
+
return;
|
|
105
|
+
if (this.execution === 'detached') {
|
|
106
|
+
// Always launch a candidate after enqueueing. The child checks ownership
|
|
107
|
+
// in the same state transaction used by the draining owner's exit.
|
|
108
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./work-daemon.js', import.meta.url)), this.store.directory, this.store.get(EPOCH_KEY) ?? ''], {
|
|
109
|
+
detached: true, stdio: 'ignore', windowsHide: true,
|
|
110
|
+
});
|
|
111
|
+
child.on('error', () => { });
|
|
112
|
+
child.unref();
|
|
113
|
+
}
|
|
114
|
+
else if (this.drainTask)
|
|
115
|
+
this.resumeRequested = true;
|
|
116
|
+
else {
|
|
117
|
+
this.resumeRequested = false;
|
|
118
|
+
this.drainTask = this.drain().catch(() => { }).finally(() => {
|
|
119
|
+
this.drainTask = undefined;
|
|
120
|
+
if (this.resumeRequested)
|
|
121
|
+
this.resume();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch { /* Pending jobs remain eligible on a later invocation. */ }
|
|
38
126
|
}
|
|
39
127
|
worker() {
|
|
40
|
-
// A signing operation borrows this cache; only the application closes it.
|
|
41
128
|
return { workBlock: (block, signal) => this.obtain(block, false, signal), close() { } };
|
|
42
129
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
130
|
+
queue() {
|
|
131
|
+
const value = this.store.get(QUEUE_KEY);
|
|
132
|
+
if (!Array.isArray(value))
|
|
133
|
+
return [];
|
|
134
|
+
return value.filter((job) => job !== null && typeof job === 'object'
|
|
135
|
+
&& ['id', 'key', 'scope', 'target', 'block'].every(key => typeof job[key] === 'string')
|
|
136
|
+
&& typeof job.height === 'string' && /^\d+$/.test(job.height)).slice(0, QUEUE_LIMIT);
|
|
49
137
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
138
|
+
/** Caller owns the short state transaction. Lower observations cannot undo
|
|
139
|
+
* newer heads; an equal-height replacement invalidates the previous fork. */
|
|
140
|
+
observe(key, head) {
|
|
141
|
+
const prior = this.store.get('head.' + key);
|
|
142
|
+
if (prior?.scope === head.scope && /^\d+$/.test(prior.height) && BigInt(prior.height) > BigInt(head.height))
|
|
143
|
+
return false;
|
|
144
|
+
this.store.set('head.' + key, head);
|
|
145
|
+
return true;
|
|
54
146
|
}
|
|
55
|
-
key(block) { return `work.${block.network.name}.${block.publicKey.toString()}`; }
|
|
56
147
|
read(block) {
|
|
57
148
|
try {
|
|
58
149
|
const record = this.store.get(this.key(block));
|
|
59
|
-
if (!record || record.
|
|
150
|
+
if (!record || !this.same(record, this.head(block)) || !/^[0-9a-f]{16}$/i.test(record.work))
|
|
60
151
|
return;
|
|
61
152
|
const work = AttoWork.Companion.parse(record.work);
|
|
62
153
|
return work.isValid(block) ? work : undefined;
|
|
@@ -65,63 +156,126 @@ export class WalletWork {
|
|
|
65
156
|
return undefined;
|
|
66
157
|
}
|
|
67
158
|
}
|
|
68
|
-
save(block, work) {
|
|
69
|
-
if (this.closed ||
|
|
159
|
+
save(block, head, work) {
|
|
160
|
+
if (this.closed || this.scope() !== head.scope)
|
|
70
161
|
return;
|
|
71
|
-
const key = this.key(block);
|
|
72
162
|
this.store.transaction(() => {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
// including work another CLI/MCP process has already persisted.
|
|
76
|
-
if (prior && /^\d+$/.test(prior.height) && BigInt(prior.height) > BigInt(block.height.toString()))
|
|
163
|
+
const observed = this.store.get('head.' + this.key(block));
|
|
164
|
+
if (observed && !this.same(observed, head))
|
|
77
165
|
return;
|
|
78
|
-
this.store.set(key, { target:
|
|
166
|
+
this.store.set(this.key(block), { scope: head.scope, target: head.target, height: head.height, work: work.toString() });
|
|
167
|
+
this.store.set(QUEUE_KEY, this.queue().filter(job => job.key !== this.key(block) || !this.same(job, head)));
|
|
79
168
|
});
|
|
80
169
|
}
|
|
81
|
-
async obtain(block, speculative
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
170
|
+
async obtain(block, speculative, signal) {
|
|
171
|
+
// Establish the shared generation before any computation takes its lock.
|
|
172
|
+
// Otherwise a first enqueue could change its scope while foreground work
|
|
173
|
+
// was already running under an uninitialized generation.
|
|
174
|
+
if (!this.store.get(EPOCH_KEY)) {
|
|
175
|
+
try {
|
|
176
|
+
this.store.transaction(() => { if (!this.store.get(EPOCH_KEY))
|
|
177
|
+
this.store.set(EPOCH_KEY, randomUUID()); });
|
|
178
|
+
}
|
|
179
|
+
catch { /* Foreground work can still use its ordinary uncached fallback. */ }
|
|
180
|
+
}
|
|
181
|
+
const key = this.scope() + ':' + this.key(block) + ':' + attoBlockWorkTarget(block);
|
|
182
|
+
const existing = this.computations.get(key);
|
|
92
183
|
if (existing) {
|
|
184
|
+
let cancel;
|
|
93
185
|
try {
|
|
94
|
-
const
|
|
95
|
-
|
|
186
|
+
const cancelled = new Promise((_, reject) => {
|
|
187
|
+
cancel = () => reject(new AttoError('CANCELLED', 'Send cancelled.'));
|
|
188
|
+
if (signal?.aborted)
|
|
189
|
+
cancel();
|
|
190
|
+
else
|
|
191
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
192
|
+
});
|
|
193
|
+
const work = await Promise.race([existing.promise, cancelled]);
|
|
96
194
|
if (work.isValid(block))
|
|
97
195
|
return work;
|
|
98
196
|
}
|
|
99
197
|
catch (error) {
|
|
100
|
-
|
|
101
|
-
// normal worker budget if that earlier background attempt failed.
|
|
102
|
-
if (speculative || !existing.speculative)
|
|
198
|
+
if (signal?.aborted || speculative || !existing.speculative)
|
|
103
199
|
throw error;
|
|
104
200
|
}
|
|
201
|
+
finally {
|
|
202
|
+
if (cancel)
|
|
203
|
+
signal?.removeEventListener('abort', cancel);
|
|
204
|
+
}
|
|
105
205
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
this.
|
|
206
|
+
const operation = this.compute(block, speculative, signal);
|
|
207
|
+
this.active.add(operation);
|
|
208
|
+
const computation = { promise: operation, speculative };
|
|
209
|
+
this.computations.set(key, computation);
|
|
110
210
|
try {
|
|
111
|
-
return await
|
|
211
|
+
return await operation;
|
|
112
212
|
}
|
|
113
213
|
finally {
|
|
114
|
-
|
|
115
|
-
|
|
214
|
+
this.active.delete(operation);
|
|
215
|
+
if (this.computations.get(key) === computation)
|
|
216
|
+
this.computations.delete(key);
|
|
116
217
|
}
|
|
117
218
|
}
|
|
118
|
-
async compute(block,
|
|
219
|
+
async compute(block, speculative, signal) {
|
|
220
|
+
if (this.closed)
|
|
221
|
+
throw new AttoError('WORK_CLOSED', 'The wallet work cache is closed.');
|
|
222
|
+
const settings = this.settings();
|
|
223
|
+
if (block.network.name !== settings.network)
|
|
224
|
+
throw new AttoError('NETWORK_MISMATCH', 'Work must use the configured wallet network.');
|
|
225
|
+
const head = this.head(block);
|
|
119
226
|
const controller = new AbortController();
|
|
120
227
|
this.requests.add(controller);
|
|
121
|
-
const
|
|
228
|
+
const combined = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
|
|
229
|
+
let timer = setTimeout(() => controller.abort(), speculative ? SPECULATIVE_MS : WORKER_MS);
|
|
230
|
+
let release;
|
|
231
|
+
let slot;
|
|
122
232
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
233
|
+
if (!speculative) {
|
|
234
|
+
try {
|
|
235
|
+
this.store.transaction(() => this.observe(this.key(block), head));
|
|
236
|
+
}
|
|
237
|
+
catch { /* Coordination/cache failure falls back to foreground generation. */ }
|
|
238
|
+
}
|
|
239
|
+
for (;;) {
|
|
240
|
+
combined.throwIfAborted();
|
|
241
|
+
if (this.scope() !== head.scope)
|
|
242
|
+
throw new AttoError('WORK_OBSOLETE', 'Wallet configuration changed during work preparation.');
|
|
243
|
+
const ready = this.read(block);
|
|
244
|
+
if (ready)
|
|
245
|
+
return ready;
|
|
246
|
+
try {
|
|
247
|
+
release = this.store.tryWorkLock(head.scope + ':' + this.key(block) + ':' + head.target);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (speculative)
|
|
251
|
+
throw error;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
if (release)
|
|
255
|
+
break;
|
|
256
|
+
await delay(25, undefined, { signal: combined });
|
|
257
|
+
}
|
|
258
|
+
if (speculative) {
|
|
259
|
+
while (!slot) {
|
|
260
|
+
for (let index = 0; index < CONCURRENCY && !slot; index++)
|
|
261
|
+
slot = this.store.tryWorkLock('speculative-slot-' + index);
|
|
262
|
+
if (!slot)
|
|
263
|
+
await delay(25, undefined, { signal: combined });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Waiting for speculation does not consume the normal generation budget.
|
|
268
|
+
clearTimeout(timer);
|
|
269
|
+
timer = setTimeout(() => controller.abort(), WORKER_MS);
|
|
270
|
+
}
|
|
271
|
+
const ready = this.read(block);
|
|
272
|
+
if (ready)
|
|
273
|
+
return ready;
|
|
274
|
+
const work = await requestWork(block, settings.workerUrl, combined);
|
|
275
|
+
try {
|
|
276
|
+
this.save(block, head, work);
|
|
277
|
+
}
|
|
278
|
+
catch { /* The validated nonce remains usable. */ }
|
|
125
279
|
return work;
|
|
126
280
|
}
|
|
127
281
|
catch (error) {
|
|
@@ -138,17 +292,92 @@ export class WalletWork {
|
|
|
138
292
|
finally {
|
|
139
293
|
clearTimeout(timer);
|
|
140
294
|
this.requests.delete(controller);
|
|
295
|
+
slot?.();
|
|
296
|
+
release?.();
|
|
141
297
|
}
|
|
142
298
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
|
|
151
|
-
|
|
299
|
+
/** Release the singleton inside the final queue transaction. An enqueue is
|
|
300
|
+
* either seen by this owner or its launch candidate can become the next owner. */
|
|
301
|
+
async runDetached(epoch) {
|
|
302
|
+
let release;
|
|
303
|
+
this.store.transaction(() => {
|
|
304
|
+
if (this.store.get(EPOCH_KEY) === epoch)
|
|
305
|
+
release = this.store.tryProcessLock('work-daemon');
|
|
306
|
+
});
|
|
307
|
+
if (!release)
|
|
308
|
+
return;
|
|
309
|
+
const unlock = () => { release?.(); release = undefined; };
|
|
310
|
+
try {
|
|
311
|
+
await this.drain(epoch, unlock);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
await this.close();
|
|
315
|
+
unlock();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
async drain(epoch = this.store.get(EPOCH_KEY), unlock) {
|
|
319
|
+
const attempted = new Set();
|
|
320
|
+
const pending = new Map();
|
|
321
|
+
const started = performance.now();
|
|
322
|
+
try {
|
|
323
|
+
for (;;) {
|
|
324
|
+
if (this.closed || this.store.get(EPOCH_KEY) !== epoch || (unlock && performance.now() - started >= WORKER_MS))
|
|
325
|
+
break;
|
|
326
|
+
const jobs = this.store.transaction(() => {
|
|
327
|
+
const queue = this.queue().filter(job => job.scope === this.scope());
|
|
328
|
+
this.store.set(QUEUE_KEY, queue);
|
|
329
|
+
const next = queue.filter(job => !attempted.has(job.id)).slice(0, CONCURRENCY - pending.size);
|
|
330
|
+
if (!next.length && !pending.size)
|
|
331
|
+
unlock?.();
|
|
332
|
+
return next;
|
|
333
|
+
});
|
|
334
|
+
if (!jobs.length && !pending.size)
|
|
335
|
+
return;
|
|
336
|
+
for (const job of jobs) {
|
|
337
|
+
attempted.add(job.id);
|
|
338
|
+
const task = (async () => {
|
|
339
|
+
const template = AttoBlock.fromJson(job.block);
|
|
340
|
+
if (!(template instanceof AttoChangeBlock))
|
|
341
|
+
return;
|
|
342
|
+
const block = new AttoChangeBlock(template.network, template.version, template.algorithm, template.publicKey, template.height, template.balance, AttoInstant.Companion.now(), template.previous, template.representativeAlgorithm, template.representativePublicKey);
|
|
343
|
+
const observed = this.store.get('head.' + job.key);
|
|
344
|
+
if (this.key(block) !== job.key || !this.same(this.head(block), job) || (observed && !this.same(observed, job)))
|
|
345
|
+
return;
|
|
346
|
+
const work = await this.obtain(block, true);
|
|
347
|
+
// Cache hits also remove jobs left by interrupted owners.
|
|
348
|
+
this.save(block, job, work);
|
|
349
|
+
})().catch(() => { }).finally(() => pending.delete(job.id));
|
|
350
|
+
pending.set(job.id, task);
|
|
351
|
+
}
|
|
352
|
+
await Promise.race([...pending.values(), delay(25)]);
|
|
353
|
+
}
|
|
152
354
|
}
|
|
355
|
+
finally {
|
|
356
|
+
if (unlock)
|
|
357
|
+
for (const controller of this.requests)
|
|
358
|
+
controller.abort();
|
|
359
|
+
await Promise.allSettled([...pending.values()]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async cancel() {
|
|
363
|
+
this.store.transaction(() => { this.store.set(EPOCH_KEY, randomUUID()); this.store.set(QUEUE_KEY, []); });
|
|
364
|
+
await this.close();
|
|
365
|
+
const deadline = performance.now() + 5000;
|
|
366
|
+
for (;;) {
|
|
367
|
+
const release = this.store.tryProcessLock('work-daemon');
|
|
368
|
+
if (release) {
|
|
369
|
+
release();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (performance.now() >= deadline)
|
|
373
|
+
throw new AttoError('WALLET_BUSY', 'Public work preparation is still stopping. Retry wallet reset.');
|
|
374
|
+
await delay(25);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async close() {
|
|
378
|
+
this.closed = true;
|
|
379
|
+
for (const controller of this.requests)
|
|
380
|
+
controller.abort();
|
|
381
|
+
await Promise.allSettled([...this.active, ...(this.drainTask ? [this.drainTask] : [])]);
|
|
153
382
|
}
|
|
154
383
|
}
|
|
@@ -28,7 +28,7 @@ export declare class WatchManager {
|
|
|
28
28
|
} | undefined;
|
|
29
29
|
id: string;
|
|
30
30
|
filter: StreamFilter;
|
|
31
|
-
status: "
|
|
31
|
+
status: "reconnecting" | "running" | "stopped" | "completed";
|
|
32
32
|
createdAt: string;
|
|
33
33
|
reconnects: number;
|
|
34
34
|
replayable: boolean;
|
|
@@ -40,7 +40,7 @@ export declare class WatchManager {
|
|
|
40
40
|
} | undefined;
|
|
41
41
|
id: string;
|
|
42
42
|
filter: StreamFilter;
|
|
43
|
-
status: "
|
|
43
|
+
status: "reconnecting" | "running" | "stopped" | "completed";
|
|
44
44
|
createdAt: string;
|
|
45
45
|
reconnects: number;
|
|
46
46
|
replayable: boolean;
|
|
@@ -60,7 +60,7 @@ export declare class WatchManager {
|
|
|
60
60
|
} | undefined;
|
|
61
61
|
id: string;
|
|
62
62
|
filter: StreamFilter;
|
|
63
|
-
status: "
|
|
63
|
+
status: "reconnecting" | "running" | "stopped" | "completed";
|
|
64
64
|
createdAt: string;
|
|
65
65
|
reconnects: number;
|
|
66
66
|
replayable: boolean;
|