mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix: resolve NostrClient initialization race condition
Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
a5c4b97085
commit
e6a73687dd
4 files changed
+158
-33
No files matched your search
@@ -0,0 +1,15 @@
|
|||||||
|
# Weekly Agent Task Complete: Race Condition Audit
|
||||||
|
|
||||||
|
- **Agent**: race-condition-agent
|
||||||
|
- **Date**: 2026-02-14
|
||||||
|
- **Cadence**: Weekly
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Audited `js/nostr/client.js` and identified a critical race condition in the initialization logic where `isInitialized` was set prematurely. Implemented a fix using the `initPromise` pattern and validated it with a new reproduction test.
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
- **Report**: [docs/race-condition-reports/weekly-race-condition-report-2026-02-14.md](../../race-condition-reports/weekly-race-condition-report-2026-02-14.md)
|
||||||
|
- **Reproduction Test**: [tests/race/nostr-client-init-race.test.mjs](../../../../tests/race/nostr-client-init-race.test.mjs)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- Monitor Sentry for `Cannot read properties of null` errors related to pool access.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Weekly Race Condition Report: 2026-02-14
|
||||||
|
|
||||||
|
## Audit Overview
|
||||||
|
- **Agent**: bitvid-race-condition-agent
|
||||||
|
- **Focus Areas**: `js/nostr/client.js`, `js/nostr/managers/ConnectionManager.js`, `js/nostr/videoEventBuffer.js`
|
||||||
|
- **Cadence**: Weekly
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
One critical race condition was identified in the `NostrClient` initialization logic. The client flagged itself as `isInitialized = true` synchronously at the start of the `init()` method, before the connection pool was actually ready. This allowed concurrent calls (or UI components checking the flag) to access the uninitialized pool, causing crashes (`TypeError: Cannot read properties of null`).
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### 1. Critical: NostrClient Initialization Race
|
||||||
|
- **Location**: `js/nostr/client.js`
|
||||||
|
- **Severity**: Critical
|
||||||
|
- **Description**: The `init()` method set `this.isInitialized = true` immediately to prevent re-entrant calls. However, `ensurePool()` and `connectToRelays()` are asynchronous. Any component checking `client.isInitialized` during this window would proceed to use the client and crash.
|
||||||
|
- **Interleaving**:
|
||||||
|
1. Caller A calls `client.init()`.
|
||||||
|
2. `client.isInitialized` becomes `true`.
|
||||||
|
3. Caller A awaits `restoreLocalData()` (async).
|
||||||
|
4. Caller B checks `client.isInitialized` -> `true`.
|
||||||
|
5. Caller B calls `client.subscribeVideos()`.
|
||||||
|
6. `subscribeVideos` calls `this.pool.sub`.
|
||||||
|
7. `this.pool` is `null` (because Caller A hasn't reached `ensurePool` yet).
|
||||||
|
8. Crash.
|
||||||
|
|
||||||
|
- **Fix Implemented**:
|
||||||
|
- Introduced `this.initPromise` to track the ongoing initialization.
|
||||||
|
- `init()` now returns the existing promise if called concurrently.
|
||||||
|
- `this.isInitialized` is only set to `true` **after** the pool is ready and relays are connected.
|
||||||
|
- Fix validated with a new reproduction test `tests/race/nostr-client-init-race.test.mjs`.
|
||||||
|
|
||||||
|
## Other Observations
|
||||||
|
|
||||||
|
### VideoEventBuffer (Low Risk)
|
||||||
|
- `js/nostr/videoEventBuffer.js` uses a debounce timer (`scheduleFlush`) and checks `document.hidden` to prevent UI thrashing.
|
||||||
|
- A potential "render race" exists where `flush()` triggers `onVideo` before NIP-71 metadata is fully hydrated. This is an intended trade-off for responsiveness (Stale-While-Revalidate pattern) and not a data corruption bug.
|
||||||
|
|
||||||
|
### ConnectionManager (Safe)
|
||||||
|
- `js/nostr/managers/ConnectionManager.js` properly guards `ensurePool` with `this.poolPromise`, which prevented double-instantiation but didn't protect the `NostrClient` wrapper from the race described above.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
- **Next Audit**: Review `js/nostr/nip07Permissions.js` and `js/nostr/nip46Client.js` for signer negotiation races.
|
||||||
|
- **Monitoring**: Watch for `Cannot read properties of null` errors in Sentry related to `pool.sub`.
|
||||||
+40
-33
@@ -570,6 +570,7 @@ export class NostrClient {
|
|||||||
this.dmDecryptor = null;
|
this.dmDecryptor = null;
|
||||||
this.dmDecryptorPromise = null;
|
this.dmDecryptorPromise = null;
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
|
this.initPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get pubkey() { return this.signerManager.pubkey; }
|
get pubkey() { return this.signerManager.pubkey; }
|
||||||
@@ -1020,43 +1021,49 @@ export class NostrClient {
|
|||||||
* @returns {Promise<void>} Resolves when the relay pool is initialized and connections are attempted.
|
* @returns {Promise<void>} Resolves when the relay pool is initialized and connections are attempted.
|
||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
if (this.isInitialized) {
|
if (this.initPromise) {
|
||||||
return;
|
return this.initPromise;
|
||||||
}
|
|
||||||
this.isInitialized = true;
|
|
||||||
|
|
||||||
devLogger.log("Connecting to relays...");
|
|
||||||
|
|
||||||
// 1. Restore cache for immediate UI render (Stale-While-Revalidate)
|
|
||||||
await this.restoreLocalData();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.scheduleStoredRemoteSignerRestore();
|
|
||||||
} catch (error) {
|
|
||||||
devLogger.warn("[nostr] Failed to schedule remote signer restoration:", error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
this.initPromise = (async () => {
|
||||||
await this.ensurePool();
|
devLogger.log("Connecting to relays...");
|
||||||
const results = await this.connectToRelays();
|
|
||||||
const successfulRelays = results
|
// 1. Restore cache for immediate UI render (Stale-While-Revalidate)
|
||||||
.filter((r) => r.success)
|
await this.restoreLocalData();
|
||||||
.map((r) => r.url);
|
|
||||||
if (successfulRelays.length === 0) {
|
try {
|
||||||
userLogger.warn(
|
this.scheduleStoredRemoteSignerRestore();
|
||||||
"[nostr] No relays connected during init. Retrying in the background.",
|
} catch (error) {
|
||||||
);
|
devLogger.warn(
|
||||||
this.scheduleRelayReconnect({ reason: "initial-connect-failed" });
|
"[nostr] Failed to schedule remote signer restoration:",
|
||||||
} else {
|
error
|
||||||
this.resetRelayReconnectState();
|
|
||||||
devLogger.log(
|
|
||||||
`Connected to ${successfulRelays.length} relay(s)`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
userLogger.error("Nostr init failed:", err);
|
try {
|
||||||
throw err;
|
await this.ensurePool();
|
||||||
}
|
const results = await this.connectToRelays();
|
||||||
|
const successfulRelays = results
|
||||||
|
.filter((r) => r.success)
|
||||||
|
.map((r) => r.url);
|
||||||
|
if (successfulRelays.length === 0) {
|
||||||
|
userLogger.warn(
|
||||||
|
"[nostr] No relays connected during init. Retrying in the background."
|
||||||
|
);
|
||||||
|
this.scheduleRelayReconnect({ reason: "initial-connect-failed" });
|
||||||
|
} else {
|
||||||
|
this.resetRelayReconnectState();
|
||||||
|
devLogger.log(`Connected to ${successfulRelays.length} relay(s)`);
|
||||||
|
}
|
||||||
|
this.isInitialized = true;
|
||||||
|
} catch (err) {
|
||||||
|
userLogger.error("Nostr init failed:", err);
|
||||||
|
this.initPromise = null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
|
||||||
|
import "../test-helpers/setup-localstorage.mjs";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { NostrClient } from "../../js/nostr/client.js";
|
||||||
|
|
||||||
|
// Mock global.window if missing
|
||||||
|
if (typeof globalThis.window === "undefined") {
|
||||||
|
globalThis.window = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("NostrClient initialization race condition: concurrent init() calls", async (t) => {
|
||||||
|
const client = new NostrClient();
|
||||||
|
|
||||||
|
// Mock ensurePool to simulate a slow initialization
|
||||||
|
let poolReady = false;
|
||||||
|
|
||||||
|
// We mock connectionManager.ensurePool via the instance
|
||||||
|
client.connectionManager.ensurePool = async () => {
|
||||||
|
// Delay to simulate async work
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Create a fake pool
|
||||||
|
const fakePool = {
|
||||||
|
sub: () => ({ on: () => {}, unsub: () => {} }),
|
||||||
|
get: async () => null,
|
||||||
|
list: async () => [],
|
||||||
|
ensureRelay: async () => {},
|
||||||
|
};
|
||||||
|
client.connectionManager.pool = fakePool;
|
||||||
|
poolReady = true;
|
||||||
|
return fakePool;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Also mock connectToRelays to avoid network calls
|
||||||
|
client.connectToRelays = async () => [];
|
||||||
|
client.restoreLocalData = async () => true;
|
||||||
|
|
||||||
|
// ACT: Call init() (Caller A)
|
||||||
|
const initPromiseA = client.init();
|
||||||
|
|
||||||
|
// ACT: Call init() again immediately (Caller B)
|
||||||
|
const initPromiseB = client.init();
|
||||||
|
|
||||||
|
// ASSERT: Caller B should receive a promise that resolves when initialization is complete.
|
||||||
|
// In the buggy version, initPromiseB is undefined because it returns early.
|
||||||
|
assert.ok(initPromiseB instanceof Promise, "Second init() call should return a Promise, not undefined");
|
||||||
|
|
||||||
|
await initPromiseB;
|
||||||
|
|
||||||
|
assert.equal(poolReady, true, "Pool should be ready after awaiting second init() call");
|
||||||
|
|
||||||
|
// Verify we can use the client
|
||||||
|
try {
|
||||||
|
client.subscribeVideos(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
assert.fail("Should be able to subscribe after awaiting init()");
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user