Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/serialize-wallet-mutations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": patch
---

Serialize wallet create, default, and delete mutations across processes so concurrent commands cannot strand wallets or leave stale configuration.
61 changes: 61 additions & 0 deletions src/__tests__/fixtures/wallet-concurrency-worker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const [repo, home, markerDir, name, password] = process.argv.slice(2);
process.env.HOME = home;
process.env.USERPROFILE = home;

const walletsDir = path.join(home, '.nansen', 'wallets');
const configPath = path.join(walletsDir, 'config.json');
const lockPath = path.join(walletsDir, '.wallet-mutation.lock');
const originalReadFileSync = fs.readFileSync.bind(fs);
const originalReaddirSync = fs.readdirSync.bind(fs);
let synchronizedConfigRead = false;
let synchronizedWalletScan = false;

function rendezvous(stage) {
fs.writeFileSync(path.join(markerDir, `${stage}-${process.pid}`), name);
const deadline = Date.now() + 10_000;
while (originalReaddirSync(markerDir).filter(file => file.startsWith(`${stage}-`)).length < 2) {
if (Date.now() > deadline) throw new Error(`Timed out at ${stage} rendezvous`);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
}
}

function mutationLockExists() {
try {
return fs.lstatSync(lockPath).isDirectory();
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}

fs.readFileSync = function patchedReadFileSync(file, ...args) {
const value = originalReadFileSync(file, ...args);
if (!synchronizedConfigRead && path.resolve(String(file)) === path.resolve(configPath)) {
synchronizedConfigRead = true;
if (!mutationLockExists()) rendezvous('config-read');
}
return value;
};

fs.readdirSync = function patchedReaddirSync(dir, ...args) {
const value = originalReaddirSync(dir, ...args);
if (!synchronizedWalletScan && path.resolve(String(dir)) === path.resolve(walletsDir)) {
synchronizedWalletScan = true;
if (!mutationLockExists()) rendezvous('wallet-scan');
}
return value;
};

try {
const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href;
const { createWallet } = await import(`${walletUrl}?worker=${process.pid}`);
const result = createWallet(name, password);
process.stdout.write(JSON.stringify({ ok: true, result }));
} catch (error) {
process.stdout.write(JSON.stringify({ ok: false, error: error.message }));
process.exitCode = 1;
}
20 changes: 20 additions & 0 deletions src/__tests__/fixtures/wallet-lock-contender.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const [repo, home, markerPath] = process.argv.slice(2);
process.env.HOME = home;
process.env.USERPROFILE = home;

try {
const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href;
const { withWalletMutationLock } = await import(`${walletUrl}?contender=${process.pid}`);
const result = withWalletMutationLock(() => {
fs.writeFileSync(markerPath, String(process.pid));
return path.basename(markerPath);
});
process.stdout.write(JSON.stringify({ ok: true, result }));
} catch (error) {
process.stdout.write(JSON.stringify({ ok: false, error: error.message, code: error.code }));
process.exitCode = 1;
}
16 changes: 16 additions & 0 deletions src/__tests__/fixtures/wallet-lock-holder.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const [repo, home, enteredPath, releasePath] = process.argv.slice(2);
process.env.HOME = home;
process.env.USERPROFILE = home;

const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href;
const { withWalletMutationLock } = await import(`${walletUrl}?holder=${process.pid}`);

withWalletMutationLock(() => {
fs.writeFileSync(enteredPath, String(process.pid));
const cell = new Int32Array(new SharedArrayBuffer(4));
while (!fs.existsSync(releasePath)) Atomics.wait(cell, 0, 0, 10);
});
57 changes: 57 additions & 0 deletions src/__tests__/fixtures/wallet-mutation-worker.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const [repo, home, markerDir, operation, name, password] = process.argv.slice(2);
process.env.HOME = home;
process.env.USERPROFILE = home;

const walletsDir = path.join(home, '.nansen', 'wallets');
const configPath = path.join(walletsDir, 'config.json');
const lockPath = path.join(walletsDir, '.wallet-mutation.lock');
const originalReadFileSync = fs.readFileSync.bind(fs);
const originalReaddirSync = fs.readdirSync.bind(fs);
const originalRenameSync = fs.renameSync.bind(fs);
let synchronizedConfigRead = false;

function lockExists() {
try { return fs.lstatSync(lockPath).isDirectory(); }
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
}

function rendezvous() {
fs.writeFileSync(path.join(markerDir, `config-read-${operation}-${process.pid}`), 'ready');
const deadline = Date.now() + 10_000;
while (originalReaddirSync(markerDir).filter(file => file.startsWith('config-read-')).length < 2) {
if (Date.now() > deadline) throw new Error('Timed out at config-read rendezvous');
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
}
}

fs.readFileSync = function patchedReadFileSync(file, ...args) {
const value = originalReadFileSync(file, ...args);
if (!synchronizedConfigRead && path.resolve(String(file)) === path.resolve(configPath)) {
synchronizedConfigRead = true;
if (!lockExists()) rendezvous();
}
return value;
};

fs.renameSync = function patchedRenameSync(from, to) {
if (operation === 'default' && path.resolve(String(to)) === path.resolve(configPath) && !lockExists()) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500);
}
return originalRenameSync(from, to);
};

try {
const walletUrl = pathToFileURL(path.join(repo, 'src', 'wallet.js')).href;
const { deleteWallet, setDefaultWallet } = await import(`${walletUrl}?mutation=${process.pid}`);
const result = operation === 'default'
? setDefaultWallet(name)
: await deleteWallet(name, password);
process.stdout.write(JSON.stringify({ ok: true, result }));
} catch (error) {
process.stdout.write(JSON.stringify({ ok: false, error: error.message }));
process.exitCode = 1;
}
Loading
Loading