Skip to content

Commit 500d5cc

Browse files
sjsyrekclaude
andcommitted
refactor(cache): improve singleton pattern defensive programming (Issue #4)
Refactored CacheService.getInstance() to make handler registration more atomic and easier to reason about. This prevents theoretical race conditions and improves code maintainability. Changes: - Calculate needsNewInstance and needsHandlerRegistration upfront - Set handlersRegistered=true BEFORE instance creation (defensive programming) - Clear separation of concerns: flag setting → instance creation → handler registration - Added comprehensive comments explaining the defensive pattern - Added non-null assertion with explanatory comment for return value - Added 4 comprehensive tests for singleton pattern: - Same instance on multiple calls - Handlers registered only once - New instance after close - Rapid getInstance calls without race condition Benefits: - More maintainable code with clearer intent - Prevents future bugs from async refactoring - Easier to understand the singleton pattern flow - Better defensive programming practices - Test coverage ensures pattern remains correct Technical Note: Node.js is single-threaded for synchronous code, so true race conditions don't occur with the current synchronous constructor. However, this defensive pattern makes the code more robust against future changes (e.g., async initialization) and follows best practices for singleton patterns. Test Coverage: - All 1446 tests passing (100% pass rate) - Added 4 new singleton pattern tests - Test count increased: 1442 → 1446 tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1471505 commit 500d5cc

3 files changed

Lines changed: 122 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- Added 6 comprehensive tests covering single/multi-language validation
2828
- Location: `src/cli/commands/translate.ts:81-92` (validation method), multiple call sites
2929

30+
### Changed
31+
- **Defensive Programming: CacheService Singleton Pattern** - Improved code robustness (Issue #4)
32+
- Refactored getInstance() to set handlersRegistered flag before instance creation
33+
- Makes handler registration more atomic and easier to reason about
34+
- Calculates needsNewInstance and needsHandlerRegistration upfront
35+
- Sets handlersRegistered=true immediately after checking, before any side effects
36+
- Prevents theoretical race conditions in async scenarios (defensive programming)
37+
- Added comprehensive comments explaining the defensive pattern
38+
- Added 4 new tests verifying singleton behavior and handler registration
39+
- **Impact**: More maintainable code with clearer intent, prevents future bugs
40+
- **Note**: Node.js is single-threaded, so actual race conditions don't occur, but this makes code more robust
41+
- Location: `src/storage/cache.ts:58-102` (getInstance method)
42+
3043
### Fixed
3144
- **Critical: Duplicate text handling in batch translation** - Fixed data loss bug for duplicate inputs
3245
- When input array contained duplicate texts (e.g., `["Hello", "Hello", "World"]`), only the last occurrence received translation

src/storage/cache.ts

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,34 +58,47 @@ export class CacheService {
5858
/**
5959
* Get singleton cache instance
6060
* Uses a shared instance to prevent resource leaks
61+
* Fix for Issue #4: Improved defensive programming with atomic handler registration
6162
*/
6263
static getInstance(options?: CacheServiceOptions): CacheService {
63-
if (!CacheService.instance || CacheService.instance.isClosed) {
64+
// Check if we need to create a new instance
65+
const needsNewInstance = !CacheService.instance || CacheService.instance.isClosed;
66+
67+
// Register handlers atomically before instance creation for defensive programming
68+
// Set flag first to prevent any theoretical race conditions (even though Node.js is single-threaded)
69+
const needsHandlerRegistration = needsNewInstance && !CacheService.handlersRegistered;
70+
if (needsHandlerRegistration) {
71+
// Set flag immediately to make the operation atomic
72+
CacheService.handlersRegistered = true;
73+
}
74+
75+
// Create instance if needed
76+
if (needsNewInstance) {
6477
CacheService.instance = new CacheService(options);
78+
}
6579

66-
// Register cleanup handlers only once to prevent memory leaks
67-
if (!CacheService.handlersRegistered) {
68-
CacheService.handlersRegistered = true;
69-
70-
// Register cleanup on process exit (use 'once' to prevent duplicate handlers)
71-
process.once('exit', () => {
72-
CacheService.instance?.close();
73-
});
74-
75-
// Handle unexpected termination
76-
process.once('SIGINT', () => {
77-
CacheService.instance?.close();
78-
process.exit(0);
79-
});
80-
81-
process.once('SIGTERM', () => {
82-
CacheService.instance?.close();
83-
process.exit(0);
84-
});
85-
}
80+
// Register cleanup handlers only once to prevent memory leaks
81+
// This happens after instance creation to ensure instance exists
82+
if (needsHandlerRegistration) {
83+
// Register cleanup on process exit (use 'once' to prevent duplicate handlers)
84+
process.once('exit', () => {
85+
CacheService.instance?.close();
86+
});
87+
88+
// Handle unexpected termination
89+
process.once('SIGINT', () => {
90+
CacheService.instance?.close();
91+
process.exit(0);
92+
});
93+
94+
process.once('SIGTERM', () => {
95+
CacheService.instance?.close();
96+
process.exit(0);
97+
});
8698
}
8799

88-
return CacheService.instance;
100+
// Instance is guaranteed to exist at this point (either already existed or just created)
101+
return CacheService.instance!;
89102
}
90103

91104
/**

tests/unit/cache-service.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,4 +473,78 @@ describe('CacheService', () => {
473473
newService.close();
474474
});
475475
});
476+
477+
describe('singleton pattern (Issue #4)', () => {
478+
it('should return same instance on multiple getInstance calls', () => {
479+
const instance1 = CacheService.getInstance({ dbPath: testCachePath });
480+
const instance2 = CacheService.getInstance({ dbPath: testCachePath });
481+
482+
expect(instance1).toBe(instance2);
483+
484+
instance1.close();
485+
});
486+
487+
it('should register exit handlers only once', () => {
488+
// Spy on process.once to verify handlers are only registered once
489+
const processOnceSpy = jest.spyOn(process, 'once');
490+
491+
// Reset the singleton (access private static field via any cast)
492+
(CacheService as any).instance = null;
493+
(CacheService as any).handlersRegistered = false;
494+
495+
// Call getInstance multiple times
496+
const instance1 = CacheService.getInstance({ dbPath: testCachePath });
497+
const instance2 = CacheService.getInstance({ dbPath: testCachePath });
498+
const instance3 = CacheService.getInstance({ dbPath: testCachePath });
499+
500+
// Should only have registered handlers once (3 calls: exit, SIGINT, SIGTERM)
501+
expect(processOnceSpy).toHaveBeenCalledTimes(3);
502+
503+
// All instances should be the same
504+
expect(instance1).toBe(instance2);
505+
expect(instance2).toBe(instance3);
506+
507+
processOnceSpy.mockRestore();
508+
instance1.close();
509+
});
510+
511+
it('should create new instance after close', () => {
512+
const instance1 = CacheService.getInstance({ dbPath: testCachePath });
513+
instance1.close();
514+
515+
const instance2 = CacheService.getInstance({ dbPath: testCachePath });
516+
517+
// Should be a different instance since first was closed
518+
expect(instance1).not.toBe(instance2);
519+
520+
instance2.close();
521+
});
522+
523+
it('should handle rapid getInstance calls without race condition', () => {
524+
// Reset singleton
525+
(CacheService as any).instance = null;
526+
(CacheService as any).handlersRegistered = false;
527+
528+
// Spy on handler registration
529+
const processOnceSpy = jest.spyOn(process, 'once');
530+
531+
// Simulate rapid calls (even though Node.js is single-threaded,
532+
// this tests the logic is correct)
533+
const instances = [];
534+
for (let i = 0; i < 10; i++) {
535+
instances.push(CacheService.getInstance({ dbPath: testCachePath }));
536+
}
537+
538+
// All should be the same instance
539+
for (let i = 1; i < instances.length; i++) {
540+
expect(instances[i]).toBe(instances[0]);
541+
}
542+
543+
// Handlers should only be registered once (3 calls: exit, SIGINT, SIGTERM)
544+
expect(processOnceSpy).toHaveBeenCalledTimes(3);
545+
546+
processOnceSpy.mockRestore();
547+
instances[0]?.close();
548+
});
549+
});
476550
});

0 commit comments

Comments
 (0)