@aaswe/codebase-ai
Version:
AI-Assisted Software Engineering (AASWE) - Rich codebase context for IDE LLMs
210 lines (149 loc) • 7.04 kB
Markdown
# When Jest Says Your Tests Pass But Your CI Says They Don't: Debugging Worker Process Timeouts
*A practical guide to fixing the "A worker process has failed to exit gracefully" error that's probably driving you crazy right now.*
## The Problem That Made Me Question Everything
Picture this: You're working on a complex TypeScript application with async operations, database connections, and timers. Your tests are green—all 227 of them passing beautifully. But then Jest drops this bomb on you:
```
Test Suites: 1 failed, 7 passed, 8 total
Tests: 227 passed, 227 total
A worker process has failed to exit gracefully and has been force exited.
This is likely caused by tests leaking due to improper teardown.
Try running with --detectOpenHandles to find leaks.
```
Wait, what? All my tests passed, but the suite failed? This is the kind of error that makes you stare at your screen for way too long, wondering if Jest is having an existential crisis.
## The Real Culprit: Your Timers Are Holding Node.js Hostage
Here's what's actually happening: Node.js won't exit while there are active timers, intervals, or other handles keeping the event loop alive. Jest knows this and gets impatient, eventually force-killing the worker process.
The most common culprits I've encountered:
1. **Timeout handles in Promise.race() operations** (this was my main issue)
2. **setInterval() calls without proper cleanup**
3. **Database connection pools that don't close**
4. **WebSocket connections left hanging**
## Step 1: Find the Smoking Gun
First, let's see what's actually keeping your process alive:
```bash
npm test -- --detectOpenHandles --forceExit
```
This will show you exactly what handles are still active. In my case, I saw:
```
Jest has detected the following 3 open handles potentially keeping Jest from exiting:
● Timeout
at Neo4jStorageLayer.query (src/services/layer2/hybrid-storage/Neo4jStorageLayer.ts:104:11)
```
Bingo! Three timeout handles in my Neo4j storage layer.
## Step 2: The Classic Promise.race() Timer Leak
Here's the pattern that was killing me:
```typescript
// ❌ This creates a timer leak
const result = await Promise.race([
session.run(query, params || {}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Query timeout')), timeout)
)
]);
```
The problem? When the query completes successfully, that `setTimeout` is still sitting there, waiting to fire. Node.js sees it and thinks, "Oh, there's still work to do!" and refuses to exit.
## Step 3: The Fix That Actually Works
Here's how I fixed it:
```typescript
// ✅ Proper timeout handle management
async query<T = any>(query: string, params?: Record<string, any>, context?: QueryContext): Promise<QueryResult<T>> {
let session: Session | undefined;
let timeoutHandle: NodeJS.Timeout | undefined;
try {
session = this.driver.session({
database: this.config.database || 'neo4j',
defaultAccessMode: this.determineAccessMode(query)
});
const timeout = context?.timeout || 30000;
const result = await Promise.race([
session.run(query, params || {}),
new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => reject(new Error('Query timeout')), timeout);
timeoutHandle.unref(); // This is the magic line
})
]);
// Clean up immediately after success
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
return processResults(result);
} catch (error) {
// Clean up on error too
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
throw error;
} finally {
// Belt and suspenders approach
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (session) {
await session.close();
}
}
}
```
The key insights here:
1. **Store the timeout handle**: You need a reference to clear it later
2. **Use `unref()`**: This tells Node.js "don't wait for this timer to exit"
3. **Clean up everywhere**: Success path, error path, and finally block
4. **Be paranoid**: Multiple cleanup points ensure nothing slips through
## Step 4: The TypeScript Bonus Round
While debugging, I also hit this TypeScript error:
```
error TS6133: 'calculateTextSimilarity' is declared but its value is never read.
```
This was preventing the tests from even running. Instead of just deleting the method (which would waste good code), I integrated it into existing functionality:
```typescript
// Before: Unused method
private calculateTextSimilarity(text1: string, text2: string): number {
// Jaccard similarity implementation
}
// After: Integrated into semantic search
private calculateSemanticSimilarity(queryTerms: Array<{...}>, triple: RDFTriple): number {
const queryText = queryTerms.map(t => t.term).join(' ');
const tripleText = `${triple.subject} ${triple.predicate} ${triple.object}`.toLowerCase();
// Now the method is actually used!
const textSimilarity = this.calculateTextSimilarity(queryText, tripleText);
let similarity = textSimilarity * 0.5;
// ... rest of semantic analysis
}
```
## Your Debugging Checklist
When you hit this issue, work through this list:
1. **Run with `--detectOpenHandles`** to see what's still active
2. **Look for Promise.race() with setTimeout** - this is the #1 culprit
3. **Check for setInterval() without clearInterval()**
4. **Verify database connections are properly closed**
5. **Make sure WebSocket connections are terminated**
6. **Use `unref()` on timers that shouldn't block exit**
## The Nuclear Option: When All Else Fails
If you're still stuck and need to ship, you can use `--forceExit`:
```bash
npm test -- --forceExit
```
But please, **don't leave it like this**. Force exit masks real problems and can hide resource leaks that will bite you in production.
## What I Learned
1. **Jest's error messages can be misleading** - "tests passed but suite failed" usually means resource cleanup issues
2. **`unref()` is your friend** - it lets Node.js exit without waiting for non-critical timers
3. **Clean up in multiple places** - success, error, and finally blocks
4. **TypeScript strict mode catches real issues** - don't ignore unused code warnings
## The Happy Ending
After implementing these fixes:
```
✅ Test Suites: 8 passed, 8 total
✅ Tests: 227 passed, 227 total
✅ No open handles detected
✅ CI pipeline green
```
No more force exits, no more mysterious failures, and my CI/CD pipeline is finally reliable again.
## TL;DR
If Jest says your worker process failed to exit gracefully:
1. Run `npm test -- --detectOpenHandles` to find the culprit
2. Look for `setTimeout` in `Promise.race()` without cleanup
3. Store timer handles and `clearTimeout()` them everywhere
4. Use `timer.unref()` to allow graceful exit
5. Clean up in try, catch, and finally blocks
Your future self (and your CI/CD pipeline) will thank you.
---
*Found this helpful? Hit me up if you run into other Jest weirdness - I've probably been there too.*