Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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