enforce no un-awaited unsafe async methods

This commit is contained in:
Mononaut 2026-02-04 10:16:40 +00:00
parent 538f92abaf
commit 5c87ffc257
No known key found for this signature in database
GPG key ID: A3F058E41374C04E
19 changed files with 56 additions and 11 deletions

View file

@ -21,6 +21,8 @@ module.exports = {
messages: {
unhandled:
'await of non-@asyncSafe callee in @asyncSafe context; use try/catch or annotate callee (@asyncSafe) or context (@asyncUnsafe)',
unhandledVoid:
'void of non-@asyncSafe callee; annotate callee with @asyncSafe or handle the promise properly',
},
},
@ -308,8 +310,8 @@ module.exports = {
return fnHasTag(m, opt.safeTag);
}
function calleeIsAnnotatedSafe(awaitNode) {
const arg = unwrapChain(awaitNode.argument);
function calleeIsAnnotatedSafe(callExpr) {
const arg = unwrapChain(callExpr);
if (!arg) return false;
if (arg.type === 'CallExpression') {
@ -368,7 +370,7 @@ module.exports = {
if (inTryBlock(node) || isHandledAwaitArg(node)) return;
// callee carries @asyncSafe? → ok anywhere
if (calleeIsAnnotatedSafe(node)) return;
if (calleeIsAnnotatedSafe(node.argument)) return;
// context explicitly @asyncUnsafe? → ok to bubble
if (contextIsAnnotatedUnsafe(node)) return;
@ -376,6 +378,20 @@ module.exports = {
// default: context is safe, callee is unsafe → error
context.report({ node, messageId: 'unhandled' });
},
// void someAsyncCall() — only allowed if callee is @asyncSafe
UnaryExpression(node) {
if (node.operator !== 'void') return;
const arg = unwrapChain(node.argument);
if (!arg || arg.type !== 'CallExpression') return;
// callee carries @asyncSafe? → ok
if (calleeIsAnnotatedSafe(node.argument)) return;
// void of non-safe callee → error
context.report({ node, messageId: 'unhandledVoid' });
},
};
},
},

View file

@ -43,6 +43,7 @@ class BackendInfo {
void this.$updateCoreVersion(); // starting immediately
}
/** @asyncSafe */
private async $updateCoreVersion(): Promise<void> {
try {
const networkInfo = await bitcoinClient.getNetworkInfo();

View file

@ -106,6 +106,7 @@ class FailoverRouter {
}
// start polling hosts to measure availability & rtt
/** @asyncSafe */
private async pollHosts(): Promise<void> {
if (this.pollTimer) {
clearTimeout(this.pollTimer);

View file

@ -660,7 +660,7 @@ class Blocks {
/**
* [INDEXING] Index transaction classification flags for Goggles
*
* @asyncUnsafe
* @asyncSafe
*/
public async $classifyBlocks(): Promise<void> {
if (this.classifyingBlocks) {
@ -1615,6 +1615,7 @@ class Blocks {
}
}
/** @asyncSafe */
public async $saveCpfp(hash: string, height: number, cpfpSummary: CpfpSummary): Promise<void> {
try {
const result = await cpfpRepository.$batchSaveClusters(cpfpSummary.clusters);

View file

@ -136,6 +136,7 @@ class ChainTips {
}
}
/** @asyncSafe */
private async $indexOrphanedBlocks(): Promise<void> {
if (this.indexingOrphanedBlocks) {
return;

View file

@ -38,6 +38,7 @@ class DiskCache {
});
}
/** @asyncSafe */
async $saveCacheToDisk(sync: boolean = false): Promise<void> {
if (!cluster.isPrimary || !config.MEMPOOL.CACHE_ENABLED) {
return;

View file

@ -42,6 +42,7 @@ class RedisCache {
}
}
/* @asyncSafe */
private async $ensureConnected(): Promise<boolean> {
if (!this.connected && config.REDIS.ENABLED) {
try {
@ -95,6 +96,7 @@ class RedisCache {
await this.$flushRbfQueues();
}
/** @asyncSafe */
async $updateBlocks(blocks: BlockExtended[]): Promise<void> {
if (!config.REDIS.ENABLED) {
return;

View file

@ -240,6 +240,7 @@ class AccelerationApi {
}
}
/** @asyncSafe */
public async connectWebsocket(): Promise<void> {
if (this.startedWebsocketLoop) {
return;

View file

@ -59,6 +59,7 @@ class StratumApi {
}
}
/** @asyncSafe */
public async connectWebsocket(): Promise<void> {
if (!config.STRATUM.ENABLED) {
return;

View file

@ -60,6 +60,7 @@ class WalletApi {
}
}
/** @asyncSafe */
private async $loadCache(): Promise<void> {
try {
const cacheData = await fsPromises.readFile(WalletApi.FILE_NAME, 'utf8');
@ -148,6 +149,7 @@ class WalletApi {
}
// resync wallet addresses from the services backend
/** @asyncSafe */
async $syncWallets(): Promise<void> {
if (!config.WALLETS.ENABLED || this.syncing) {
return;

View file

@ -182,7 +182,8 @@ import { execSync } from 'child_process';
if (this.pool === null) {
this.pool = createPool(this.poolConfig);
this.pool.on('connection', function (newConnection: PoolConnection) {
void newConnection.query(`SET time_zone='+00:00'`);
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- callback API, not a promise despite types
newConnection.query(`SET time_zone='+00:00'`);
});
}
return this.pool;

View file

@ -96,6 +96,7 @@ class Server {
}
}
/** @asyncSafe */
async startServer(worker = false): Promise<void> {
logger.notice(`Starting Mempool Server${worker ? ' (worker)' : ''}... (${backendInfo.getShortCommitHash()})`);
@ -237,6 +238,7 @@ class Server {
void poolsUpdater.$startService();
}
/** @asyncSafe */
async runMainUpdateLoop(): Promise<void> {
const start = Date.now();
try {

View file

@ -127,6 +127,8 @@ class Indexer {
* Runs a single task immediately
*
* (use `scheduleSingleTask` instead to queue a task to run after some timeout)
*
* @asyncSafe
*/
public async runSingleTask(task: TaskName): Promise<void> {
if (!Common.indexingEnabled() || this.tasksRunning[task]) {
@ -165,7 +167,7 @@ class Indexer {
this.tasksRunning[task] = false;
}
/** @asyncUnsafe */
/** @asyncSafe */
public async $run(): Promise<void> {
if (!Common.indexingEnabled() || this.runIndexer === false ||
this.indexerRunning === true || mempool.hasPriority()

View file

@ -31,6 +31,7 @@ export interface PublicAcceleration {
class AccelerationRepository {
private bidBoostV2Activated = 831580;
/** @asyncSafe */
public async $saveAcceleration(acceleration: AccelerationInfo, block: IEsploraApi.Block, pool_id: number, accelerationData: Acceleration[]): Promise<void> {
const accelerationMap: { [txid: string]: Acceleration } = {};
for (const acc of accelerationData) {

View file

@ -112,7 +112,9 @@ class NetworkSyncService {
await nodesApi.$setNodesInactive(graphNodesPubkeys);
if (config.MAXMIND.ENABLED) {
void $lookupNodeLocation();
$lookupNodeLocation().catch((e) => {
logger.err(`Error in $lookupNodeLocation: ${e instanceof Error ? e.message : e}`);
});
}
}

View file

@ -58,7 +58,9 @@ class FundingTxFetcher {
elapsedSeconds = Math.round((new Date().getTime() / 1000) - cacheTimer);
if (elapsedSeconds > 60) {
logger.debug(`Saving ${Object.keys(this.fundingTxCache).length} funding txs cache into disk`, logger.tags.ln);
void fsPromises.writeFile(CACHE_FILE_NAME, JSON.stringify(this.fundingTxCache));
fsPromises.writeFile(CACHE_FILE_NAME, JSON.stringify(this.fundingTxCache)).catch((e) => {
logger.err(`Error saving funding txs cache to disk: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
});
cacheTimer = new Date().getTime() / 1000;
}
}
@ -66,7 +68,9 @@ class FundingTxFetcher {
if (this.channelNewlyProcessed > 0) {
logger.info(`Indexed ${this.channelNewlyProcessed} additional channels funding tx`, logger.tags.ln);
logger.debug(`Saving ${Object.keys(this.fundingTxCache).length} funding txs cache into disk`, logger.tags.ln);
void fsPromises.writeFile(CACHE_FILE_NAME, JSON.stringify(this.fundingTxCache));
fsPromises.writeFile(CACHE_FILE_NAME, JSON.stringify(this.fundingTxCache)).catch((e) => {
logger.err(`Error saving funding txs cache to disk: ${e instanceof Error ? e.message : e}`, logger.tags.ln);
});
}
this.running = false;

View file

@ -19,6 +19,7 @@ class PoolsUpdater {
poolsUrl: string = config.MEMPOOL.POOLS_JSON_URL;
treeUrl: string = config.MEMPOOL.POOLS_JSON_TREE_URL;
/** @asyncSafe */
public async $startService(): Promise<void> {
while ('Bitcoin is still alive') {
try {

View file

@ -135,6 +135,7 @@ class PriceUpdater {
this.latestGoodPrices = JSON.parse(JSON.stringify(this.latestPrices));
}
/** @asyncSafe */
public async $run(): Promise<void> {
if (['testnet', 'signet', 'testnet4', 'regtest'].includes(config.MEMPOOL.NETWORK)) {
// Coins have no value on testnet/signet, so we want to always show 0

View file

@ -21,6 +21,8 @@ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFT
OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import logger from "../logger";
/*
How it works:
`this._head` is an instance of `Node` which keeps track of its current value and nests
@ -143,7 +145,7 @@ export default function pLimit(concurrency: number): LimitFunction {
const enqueue = (fn, resolve, args) => {
queue.enqueue(run.bind(undefined, fn, resolve, args));
void (
(
/** @asyncUnsafe */
async () => {
// This function needs to wait until the next microtask before comparing
@ -155,7 +157,9 @@ export default function pLimit(concurrency: number): LimitFunction {
if (activeCount < concurrency && queue.size > 0) {
queue.dequeue()();
}
})();
})().catch((e) => {
logger.err(`Error in pLimit enqueue: ${e instanceof Error ? e.message : e}`);
});
};
const generator = (fn, ...args) =>