mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-05 12:21:56 +00:00
Merge pull request #401 from PR0M3TH3AN/agents/weekly/race-condition-2026-02-21-5109170834766607482
fix(scheduler): resolve relay health and publisher race conditions
This commit is contained in:
8 files changed
+218
-65
No files matched your search
@@ -0,0 +1,44 @@
|
||||
# Weekly Race Condition Report - 2026-02-21
|
||||
|
||||
## Focus Area
|
||||
**Background services and workers**, specifically:
|
||||
- `src/relay-health.mjs` (Relay health probing)
|
||||
- `src/lock-publisher.mjs` (Lock publication orchestration)
|
||||
- `src/services/memory/scheduler.js` (Memory job scheduling)
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. Resource Leak in Relay Health Probes (Critical)
|
||||
**File:** `src/relay-health.mjs`
|
||||
**Description:** The `probeWebSocketReachability` and `probePublishRead` functions wrapped a `new Promise` (containing WebSocket logic) with `withTimeout`. However, the WebSocket instance was created inside the promise executor, and there was no mechanism to close it if the `withTimeout` race was won by the timeout.
|
||||
**Impact:** If a relay connection hung, `withTimeout` would throw, but the underlying WebSocket connection attempt would continue in the background. This leads to a leak of WebSocket connections and file descriptors, which is critical for a long-running scheduler.
|
||||
**Likelihood:** High (network timeouts are common).
|
||||
|
||||
### 2. Unhandled Promise Rejection in Lock Publisher (High)
|
||||
**File:** `src/lock-publisher.mjs`
|
||||
**Description:** `publishToRelays` uses `Promise.allSettled` on an array of promises wrapped with `withTimeout`. If `withTimeout` rejects (due to timeout), the original `publishPromise` (from `nostr-tools`) continues running. If that original promise subsequently rejects (e.g., network error), that rejection is unhandled because the parent promise (`Promise.allSettled`'s element) has already settled.
|
||||
**Impact:** Unhandled promise rejections can crash the Node.js process or cause instability.
|
||||
**Likelihood:** Medium (requires a publish to fail *after* the timeout duration).
|
||||
|
||||
### 3. Potential Scheduler Overlap (Low/Acceptable)
|
||||
**File:** `src/services/memory/scheduler.js`
|
||||
**Description:** `startFixedIntervalJob` uses `setInterval` without waiting for the previous job to complete. This allows overlap if a job takes longer than its interval.
|
||||
**Assessment:** The jobs using this pattern (`consolidateObservations`, `pruningCycle`) have very long intervals (1 hour, 1 day) and use locking (`lockProvider`). The lock provider correctly prevents concurrent execution (either by skipping or waiting). This behavior is deemed acceptable for now.
|
||||
|
||||
## Fixes Implemented
|
||||
|
||||
### Fix 1: WebSocket Cleanup
|
||||
Modified `src/relay-health.mjs` to ensure the WebSocket is closed if the probe times out.
|
||||
- **Approach:** Wrapped the probe logic in a `try...catch` block. If `withTimeout` throws (timeout), the `catch` block explicitly calls `ws.close()` if `ws` exists.
|
||||
|
||||
### Fix 2: Suppress Unhandled Rejections
|
||||
Modified `src/lock-publisher.mjs` to attach a no-op `.catch()` handler to the inner publish promises.
|
||||
- **Approach:** `publishPromise.catch(() => {})` ensures that any late rejection is strictly handled, preventing process crashes, while `withTimeout` still governs the control flow.
|
||||
|
||||
## Verification
|
||||
- **Static Analysis:** Verified that the new code paths correctly handle the timeout/error scenarios.
|
||||
- **Lint:** `npm run lint` will be run to ensure no syntax errors.
|
||||
|
||||
## Next Steps
|
||||
- Monitor relay health logs to ensure no degradation in probe reliability.
|
||||
- Consider `AbortSignal` integration if `nostr-tools` updates to support it for cleaner cancellation.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Race Condition Context - 2026-02-21
|
||||
|
||||
## Overview
|
||||
This run focused on identifying and fixing race conditions in background services, specifically related to `nostr-tools` interactions and WebSocket resource management.
|
||||
|
||||
## Goals
|
||||
- Identify resource leaks in long-running processes (scheduler/relays).
|
||||
- Prevent unhandled promise rejections that could destabilize the application.
|
||||
|
||||
## Constraints
|
||||
- Must not introduce new dependencies.
|
||||
- Must preserve existing retry logic and health check behavior.
|
||||
|
||||
## Assumptions
|
||||
- `nostr-tools` implementation of `pool.publish` does not support `AbortSignal` for cancellation.
|
||||
- WebSocket connections in `relay-health.mjs` are the primary source of file descriptor leaks if not closed on timeout.
|
||||
|
||||
## Changes
|
||||
- `src/relay-health.mjs`: Added explicit `ws.close()` in `catch` blocks for timeout scenarios.
|
||||
- `src/lock-publisher.mjs`: Added `.catch(() => {})` to publish promises to suppress unhandled rejections.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Decisions - 2026-02-21 Race Condition Agent
|
||||
|
||||
## Decision: Explicit WebSocket Closure on Timeout
|
||||
|
||||
**Context:** The existing `probeWebSocketReachability` function in `src/relay-health.mjs` leaked connections when timeouts occurred.
|
||||
**Decision:** We chose to extract the WebSocket connection management logic so that we can catch timeout exceptions from `withTimeout` and explicitly close the socket.
|
||||
**Alternatives Considered:** Relying on `ws.close()` inside the promise executor (which is unreachable after timeout).
|
||||
**Rationale:** Explicit closure is the only reliable way to prevent file descriptor leaks in this scenario.
|
||||
|
||||
## Decision: Suppress Unhandled Rejections via `.catch()`
|
||||
|
||||
**Context:** `src/lock-publisher.mjs` had potential for unhandled promise rejections if `publishPromise` failed after `withTimeout` settled.
|
||||
**Decision:** Attached a no-op `.catch(() => {})` handler to `publishPromise`.
|
||||
**Rationale:** This prevents the runtime from flagging unhandled rejections, while preserving the existing logic flow.
|
||||
@@ -109,11 +109,15 @@ function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secure
|
||||
async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) {
|
||||
const publishPromises = pool.publish(relays, event);
|
||||
const settled = await Promise.allSettled(
|
||||
publishPromises.map((publishPromise, index) => withTimeout(
|
||||
publishPromise,
|
||||
publishTimeoutMs,
|
||||
`[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`,
|
||||
)),
|
||||
publishPromises.map((publishPromise, index) => {
|
||||
// Prevent unhandled rejection if timeout wins
|
||||
publishPromise.catch(() => {});
|
||||
return withTimeout(
|
||||
publishPromise,
|
||||
publishTimeoutMs,
|
||||
`[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return settled.map((result, index) => {
|
||||
|
||||
+84
-60
@@ -67,28 +67,40 @@ export function evaluateAlertThresholds(currentResult, historyEntries, threshold
|
||||
|
||||
async function probeWebSocketReachability(relayUrl, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
return withTimeout(new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(relayUrl);
|
||||
let settled = false;
|
||||
let ws;
|
||||
try {
|
||||
return await withTimeout(new Promise((resolve, reject) => {
|
||||
ws = new WebSocket(relayUrl);
|
||||
let settled = false;
|
||||
|
||||
const finish = (fn) => (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const finish = (fn) => (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fn(value);
|
||||
};
|
||||
|
||||
ws.once('open', finish(() => resolve({ ok: true, latencyMs: Date.now() - startedAt })));
|
||||
ws.once('error', finish((err) => reject(err)));
|
||||
}), timeoutMs, `WebSocket reachability timeout after ${timeoutMs}ms`);
|
||||
} catch (error) {
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fn(value);
|
||||
};
|
||||
|
||||
ws.once('open', finish(() => resolve({ ok: true, latencyMs: Date.now() - startedAt })));
|
||||
ws.once('error', finish((err) => reject(err)));
|
||||
}), timeoutMs, `WebSocket reachability timeout after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function probePublishRead(relayUrl, namespace, timeoutMs) {
|
||||
const ws = new WebSocket(relayUrl);
|
||||
let ws;
|
||||
const sk = generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
const createdAt = Math.floor(Date.now() / MS_PER_SECOND);
|
||||
@@ -106,59 +118,71 @@ async function probePublishRead(relayUrl, namespace, timeoutMs) {
|
||||
|
||||
const subId = `probe-${nonce}`;
|
||||
|
||||
return withTimeout(new Promise((resolve, reject) => {
|
||||
let okAck = false;
|
||||
let readSeen = false;
|
||||
let settled = false;
|
||||
try {
|
||||
return await withTimeout(new Promise((resolve, reject) => {
|
||||
ws = new WebSocket(relayUrl);
|
||||
let okAck = false;
|
||||
let readSeen = false;
|
||||
let settled = false;
|
||||
|
||||
const settle = (fn) => (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const settle = (fn) => (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fn(value);
|
||||
};
|
||||
|
||||
ws.once('open', () => {
|
||||
ws.send(JSON.stringify(['EVENT', event]));
|
||||
ws.send(JSON.stringify(['REQ', subId, { ids: [event.id], limit: 1 }]));
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
// Ignore malformed messages. The relay might be sending non-JSON data
|
||||
// or we might have a protocol mismatch. We only care about valid JSON.
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(message) || message.length < 2) return;
|
||||
const type = message[0];
|
||||
|
||||
if (type === 'OK' && message[1] === event.id) {
|
||||
okAck = Boolean(message[2]);
|
||||
if (!okAck) {
|
||||
settle(reject)(new Error(`Relay rejected health probe event: ${String(message[3] || 'unknown reason')}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'EVENT' && message[1] === subId && message[2]?.id === event.id) {
|
||||
readSeen = true;
|
||||
}
|
||||
|
||||
if (okAck && readSeen) {
|
||||
ws.send(JSON.stringify(['CLOSE', subId]));
|
||||
settle(resolve)({ ok: true, eventId: event.id });
|
||||
}
|
||||
});
|
||||
|
||||
ws.once('error', settle(reject));
|
||||
}), timeoutMs, `Publish/read probe timeout after ${timeoutMs}ms`);
|
||||
} catch (error) {
|
||||
if (ws) {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
fn(value);
|
||||
};
|
||||
|
||||
ws.once('open', () => {
|
||||
ws.send(JSON.stringify(['EVENT', event]));
|
||||
ws.send(JSON.stringify(['REQ', subId, { ids: [event.id], limit: 1 }]));
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
// Ignore malformed messages. The relay might be sending non-JSON data
|
||||
// or we might have a protocol mismatch. We only care about valid JSON.
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(message) || message.length < 2) return;
|
||||
const type = message[0];
|
||||
|
||||
if (type === 'OK' && message[1] === event.id) {
|
||||
okAck = Boolean(message[2]);
|
||||
if (!okAck) {
|
||||
settle(reject)(new Error(`Relay rejected health probe event: ${String(message[3] || 'unknown reason')}`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'EVENT' && message[1] === subId && message[2]?.id === event.id) {
|
||||
readSeen = true;
|
||||
}
|
||||
|
||||
if (okAck && readSeen) {
|
||||
ws.send(JSON.stringify(['CLOSE', subId]));
|
||||
settle(resolve)({ ok: true, eventId: event.id });
|
||||
}
|
||||
});
|
||||
|
||||
ws.once('error', settle(reject));
|
||||
}), timeoutMs, `Publish/read probe timeout after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readHistory(historyPath) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Test Log - 2026-02-21 Race Condition Agent
|
||||
|
||||
## Validation Status
|
||||
- Linting checks passed successfully.
|
||||
|
||||
## Static Analysis Findings
|
||||
- `src/relay-health.mjs`: Resource leak fixed.
|
||||
- `src/lock-publisher.mjs`: Unhandled rejection fixed.
|
||||
@@ -0,0 +1,12 @@
|
||||
# Race Condition TODOs - 2026-02-21
|
||||
|
||||
## Completed
|
||||
|
||||
- [x] Fix WebSocket leak in `probeWebSocketReachability` (src/relay-health.mjs)
|
||||
- [x] Fix WebSocket leak in `probePublishRead` (src/relay-health.mjs)
|
||||
- [x] Suppress unhandled promise rejection in `publishToRelays` (src/lock-publisher.mjs)
|
||||
|
||||
## Pending
|
||||
|
||||
- [ ] Investigate if `nostr-tools` supports `AbortSignal` for cleaner cancellation in future updates.
|
||||
- [ ] Monitor long-running processes for file descriptor counts (potential additional leaks).
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
agent: race-condition-agent
|
||||
cadence: weekly
|
||||
run-start: 2026-02-21-05-06-06
|
||||
status: completed
|
||||
---
|
||||
|
||||
# Weekly Race Condition Agent Run
|
||||
|
||||
## Summary
|
||||
The race condition audit was successfully executed. Two critical race conditions were identified and fixed in `src/relay-health.mjs` (resource leak) and `src/lock-publisher.mjs` (unhandled promise rejection). A report was generated.
|
||||
|
||||
## Artifacts
|
||||
- **Report**: `reports/race-condition/weekly-race-condition-report-2026-02-21.md`
|
||||
- **Memory**: `memory-update.md` (stored)
|
||||
- **Context**: `src/context/CONTEXT_2026-02-21_race-condition.md`
|
||||
- **Todo**: `src/todo/TODO_2026-02-21_race-condition.md`
|
||||
- **Decisions**: `src/decisions/DECISIONS_2026-02-21_race-condition.md`
|
||||
- **Test Logs**: `src/test_logs/TEST_LOG_2026-02-21_race-condition.md`
|
||||
|
||||
## Changes
|
||||
- Modified `src/relay-health.mjs` to ensure explicit WebSocket closure on timeout.
|
||||
- Modified `src/lock-publisher.mjs` to suppress unhandled promise rejections after timeout.
|
||||
- Created `reports/race-condition/`.
|
||||
|
||||
## Status
|
||||
Completed successfully.
|
||||
Reference in new issue
Block a user