Skip to content

Commit 81d20bf

Browse files
sjsyrekclaude
andcommitted
fix: resolve critical code quality issues identified in review
Critical Fixes: - Add timeout (90min) and max attempts (180) to document translation polling to prevent infinite loops if DeepL API status doesn't update - Fix cache service memory leak by preventing duplicate event handler registration using handlersRegistered flag and process.once() instead of process.on() - Fix unsafe array assignment in translateBatch() using explicit type constructor - Fix Promise-in-setTimeout warning in watch service with proper async wrapper High Priority Fixes: - Replace console.warn with Logger.warn in DeepL client for consistent logging - Add concurrency validation (1-100) in BatchTranslationService - Optimize getSupportedFileTypes() to return readonly reference instead of copy - Fix file size null handling with better error messages Code Quality Improvements: - Improve API error messages with request context for easier debugging - Fix multi-language file translation routing logic - Update tests to match improved error messages All 1140 tests passing (100% pass rate, 40 test suites) No linter errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4c05be3 commit 81d20bf

11 files changed

Lines changed: 373 additions & 124 deletions

File tree

CHANGELOG.md

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Critical: Document translation infinite loop risk** - Added timeout and max attempts to prevent infinite polling
12+
- Added `MAX_POLL_ATTEMPTS` (180 attempts) and `TOTAL_TIMEOUT_MS` (90 minutes) constants
13+
- Polling now terminates after 180 attempts or 90 minutes total time
14+
- Prevents CLI from hanging indefinitely if DeepL API status doesn't update
15+
- Clear error messages indicate timeout exceeded and suggest document may still be processing
16+
- **Impact**: Previously, misbehaving API responses could cause infinite loops
17+
- Location: `src/services/document-translation.ts:136-191`
18+
19+
- **Critical: Cache service memory leak** - Fixed duplicate event handler registration
20+
- Added `handlersRegistered` flag to prevent registering exit handlers multiple times
21+
- Now uses `process.once()` instead of `process.on()` for cleanup handlers
22+
- Prevents memory leak when `getInstance()` called multiple times in tests or long-running processes
23+
- **Impact**: Previously, each `getInstance()` call added new event handlers to process
24+
- Location: `src/storage/cache.ts:62-89`
25+
26+
- **High Priority: Type safety violations** - Fixed 2 linter errors
27+
- Fixed unsafe array assignment in `translateBatch()` using explicit type constructor
28+
- Fixed Promise-in-setTimeout warning by properly wrapping async callback
29+
- **Impact**: Improved type safety and eliminated compiler warnings
30+
- Locations: `src/services/translation.ts:163`, `src/services/watch.ts:156-178`
31+
32+
- **Logging Consistency** - Replaced console.warn with Logger.warn in DeepL client
33+
- Replaced direct `console.warn()` with `Logger.warn()` for proxy URL warnings
34+
- Ensures all logging respects quiet mode (`--quiet` flag)
35+
- Maintains consistent logging patterns across entire codebase
36+
- **Impact**: Previously, proxy warnings would bypass quiet mode
37+
- Location: `src/api/deepl-client.ts:177`
38+
1039
### Added
1140
- **XML Tag Validation** - Comprehensive validation for XML tag handling options
1241
- Validates `--splitting-tags`, `--non-splitting-tags`, and `--ignore-tags` parameters
@@ -47,6 +76,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4776
- **Impact**: Previously, translation errors in watch mode could be silently ignored
4877
- Location: `src/services/watch.ts:156-170`
4978

79+
- **Critical: Watch service race condition on stop** - Fixed race condition in file translation
80+
- Debounced translation timers could fire after watch service was stopped
81+
- Added guard check to prevent translations from running after `stop()` is called
82+
- Prevents "Cannot read property of null" errors when translations execute after cleanup
83+
- **Impact**: Previously, stopping watch mode could cause unhandled errors from pending translations
84+
- Location: `src/services/watch.ts` debounce timer callback
85+
5086
- **Critical: Race condition in document translation polling** - Fixed concurrent execution bug
5187
- AbortSignal cleanup and timeout completion could execute simultaneously
5288
- Added `isSettled` flag to ensure only one path (resolve or reject) executes
@@ -55,12 +91,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5591
- Race occurred when: timeout fires at same moment as abort signal
5692
- Location: `src/services/document-translation.ts:195-224`
5793

58-
- **Critical: Silent data loss in batch translation** - Added user warning for failures
59-
- Batch translations could silently filter out failed translations without notification
60-
- Now logs warning when some translations fail: "⚠️ Warning: N of M translations failed silently"
61-
- Helps users identify incomplete batch operations instead of assuming success
62-
- **Impact**: Previously, users wouldn't know if 3 out of 10 translations failed
63-
- Location: `src/services/translation.ts:225-230`
94+
- **Critical: Batch translation failure handling** - Improved error propagation and user feedback
95+
- Fixed batch translation to properly track failures and warn users about partial failures
96+
- When all batches fail, now throws error instead of returning empty array
97+
- When some batches fail, logs warning: "⚠️ Warning: N of M translations failed"
98+
- Helps users identify incomplete batch operations and propagates errors correctly
99+
- **Impact**: Previously, all-failure case returned empty array; partial failures were silent
100+
- Location: `src/services/translation.ts:199-260`
101+
102+
- **High Priority: Resource leaks in cache service** - Implemented proper disposal pattern
103+
- CacheService singleton now automatically closes database on process exit
104+
- Added handlers for `exit`, `SIGINT`, and `SIGTERM` signals
105+
- Prevents "database is locked" errors and ensures clean shutdowns
106+
- Added `isClosed` flag to prevent double-close errors
107+
- **Impact**: Previously, process termination could leave database connections open
108+
- Location: `src/storage/cache.ts` singleton getInstance()
109+
110+
- **High Priority: Non-cryptographic random in security context** - Replaced with crypto.randomBytes
111+
- Variable placeholder generation now uses `crypto.randomBytes()` instead of `Math.random()`
112+
- Uses cryptographically secure random bytes with SHA-256 hashing
113+
- Eliminates collision risk in high-volume translation scenarios
114+
- **Impact**: Previously, `Math.random()` could produce collisions in variable placeholders
115+
- Location: `src/services/translation.ts:369-381` preserveVariables()
116+
117+
- **High Priority: Silent proxy URL errors** - Added validation warnings
118+
- Invalid proxy URLs now log warning instead of silently failing
119+
- Helps users identify proxy configuration issues early
120+
- Warning: "⚠️ Warning: Invalid proxy URL: [url]. Proxy will not be used."
121+
- **Impact**: Previously, invalid proxy URLs caused silent failures in HTTP requests
122+
- Location: `src/api/deepl-client.ts:174-176`
123+
124+
- **Performance: Concurrency validation** - Added bounds checking
125+
- BatchTranslationService now validates concurrency is between 1-100
126+
- Prevents invalid concurrency values that could cause performance issues
127+
- Throws descriptive error for out-of-bounds values
128+
- **Impact**: Previously, invalid concurrency values could cause unexpected behavior
129+
- Location: `src/services/batch-translation.ts` constructor
130+
131+
- **Performance: Unnecessary array copy in getSupportedFileTypes** - Optimized to return readonly reference
132+
- Changed return type from copied array to `readonly string[]`
133+
- Eliminates unnecessary memory allocation on every call
134+
- TypeScript enforces immutability at compile time
135+
- **Impact**: Reduces memory allocations for frequently called method
136+
- Location: `src/services/file-translation.ts:35-37`
137+
138+
- **Logic Bug: File size null handling** - Fixed error handling for missing files
139+
- `getFileSize()` now correctly handles case when file doesn't exist
140+
- Returns `null` instead of throwing, allowing caller to handle gracefully
141+
- Improved error message: "File not found or cannot be accessed: [path]"
142+
- **Impact**: Previously, missing files could cause unclear errors
143+
- Location: `src/cli/commands/translate.ts:167-175`
144+
145+
- **Code Smell: Redundant null check in watch service** - Removed unnecessary check
146+
- Removed redundant null check after non-null assertion operator
147+
- Code already used `!` operator, making additional check unreachable
148+
- **Impact**: Cleaner code, no behavior change
149+
- Location: `src/services/watch.ts:208-210`
150+
151+
- **Code Smell: Deprecated _batchOptions parameter** - Removed unused parameter
152+
- Removed deprecated `_batchOptions` parameter from `translateBatch()` method signature
153+
- Parameter was never used and cluttered the API
154+
- **Impact**: Cleaner API surface, no behavior change
155+
- Location: `src/services/translation.ts:139-142`
156+
157+
- **UX: Poor API error messages** - Added context to error messages
158+
- API errors now include request details for easier debugging
159+
- Example: "Translation count mismatch: sent 2 texts but received 1 translations. Target language: es"
160+
- Example: "No translation returned from DeepL API. Request: translate text (150 chars) to de"
161+
- **Impact**: Users can now understand what went wrong without inspecting code
162+
- Location: `src/api/deepl-client.ts` translate() and translateBatch()
163+
164+
- **UX: Cache disabled logging** - Added informative logging
165+
- Now logs when cache is disabled globally: "ℹ️ Cache is disabled"
166+
- Now logs when cache is bypassed per-request: "ℹ️ Cache bypassed for this request (--no-cache)"
167+
- Helps users understand why translations aren't being cached
168+
- **Impact**: Users no longer confused about caching behavior
169+
- Location: `src/services/translation.ts:88-92`
64170

65171
## [0.7.0] - 2025-10-16
66172

src/api/deepl-client.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
GlossaryLanguagePair,
1919
} from '../types';
2020
import { normalizeGlossaryInfo, GlossaryApiResponse } from '../types/glossary.js';
21+
import { Logger } from '../utils/logger.js';
2122

2223
interface ProxyConfig {
2324
protocol?: 'http' | 'https';
@@ -171,8 +172,9 @@ export class DeepLClient {
171172
password: url.password,
172173
};
173174
}
174-
} catch {
175-
// Invalid proxy URL, ignore and continue without proxy
175+
} catch (error) {
176+
// Invalid proxy URL, warn user and continue without proxy
177+
Logger.warn(`⚠️ Invalid proxy URL "${proxyUrl}", proceeding without proxy. Error: ${error instanceof Error ? error.message : String(error)}`);
176178
}
177179
}
178180
}
@@ -211,12 +213,12 @@ export class DeepLClient {
211213
);
212214

213215
if (!response.translations || response.translations.length === 0) {
214-
throw new Error('No translation returned');
216+
throw new Error(`No translation returned from DeepL API. Request: translate text (${text.length} chars) to ${options.targetLang}`);
215217
}
216218

217219
const translation = response.translations[0];
218220
if (!translation) {
219-
throw new Error('No translation returned');
221+
throw new Error(`Empty translation in API response. Request: translate text (${text.length} chars) to ${options.targetLang}`);
220222
}
221223

222224
return {
@@ -255,12 +257,12 @@ export class DeepLClient {
255257
);
256258

257259
if (!response.translations) {
258-
throw new Error('No translations returned');
260+
throw new Error(`No translations returned from DeepL API. Request: batch translate ${texts.length} texts to ${options.targetLang}`);
259261
}
260262

261263
// Verify we got the same number of translations as texts
262264
if (response.translations.length !== texts.length) {
263-
throw new Error('Mismatch between texts sent and translations received');
265+
throw new Error(`Translation count mismatch: sent ${texts.length} texts but received ${response.translations.length} translations. Target language: ${options.targetLang}`);
264266
}
265267

266268
// Map response translations to results

src/cli/commands/translate.ts

Lines changed: 31 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -182,32 +182,7 @@ export class TranslateCommand {
182182
throw new Error('Output file path is required for file translation. Use --output <path>');
183183
}
184184

185-
// Smart routing for text-based files
186-
// Use text API (cached) for small text files, document API for large files or binaries
187-
if (this.isTextBasedFile(filePath)) {
188-
// Get file size once to avoid duplicate stat() calls
189-
const fileSize = this.getFileSize(filePath);
190-
191-
if (fileSize !== null && fileSize <= SAFE_TEXT_SIZE_LIMIT) {
192-
// Use text API with caching for small text-based files
193-
return this.translateTextFile(filePath, options);
194-
} else if (fileSize !== null && this.documentTranslationService.isDocumentSupported(filePath)) {
195-
// Text file too large for cached API, fall back to document API with warning
196-
const fileSizeKiB = (fileSize / 1024).toFixed(1);
197-
const warning = `⚠ File exceeds 100 KiB limit for cached translation (${fileSizeKiB} KiB), using document API instead`;
198-
Logger.warn(warning);
199-
const result = await this.translateDocument(filePath, options);
200-
return `${warning}\n${result}`;
201-
}
202-
// If text file is large and not supported by document API, fall through to file translation service
203-
}
204-
205-
// Check if it's a binary document (PDF, DOCX, etc.)
206-
if (this.documentTranslationService.isDocumentSupported(filePath)) {
207-
return this.translateDocument(filePath, options);
208-
}
209-
210-
// Check if translating to multiple languages
185+
// Check if translating to multiple languages (must be done BEFORE text file optimization)
211186
if (options.to.includes(',')) {
212187
const targetLangs = options.to.split(',').map(lang => lang.trim()) as Language[];
213188

@@ -237,7 +212,36 @@ export class TranslateCommand {
237212
results.map(r => ` [${r.targetLang}] ${r.outputPath}`).join('\n');
238213
}
239214

240-
// Single language translation
215+
// Smart routing for text-based files
216+
// Use text API (cached) for small text files, document API for large files or binaries
217+
if (this.isTextBasedFile(filePath)) {
218+
// Get file size once to avoid duplicate stat() calls
219+
const fileSize = this.getFileSize(filePath);
220+
221+
if (fileSize === null) {
222+
throw new Error(`File not found or cannot be accessed: ${filePath}`);
223+
}
224+
225+
if (fileSize <= SAFE_TEXT_SIZE_LIMIT) {
226+
// Use text API with caching for small text-based files
227+
return this.translateTextFile(filePath, options);
228+
} else if (this.documentTranslationService.isDocumentSupported(filePath)) {
229+
// Text file too large for cached API, fall back to document API with warning
230+
const fileSizeKiB = (fileSize / 1024).toFixed(1);
231+
const warning = `⚠ File exceeds 100 KiB limit for cached translation (${fileSizeKiB} KiB), using document API instead`;
232+
Logger.warn(warning);
233+
const result = await this.translateDocument(filePath, options);
234+
return `${warning}\n${result}`;
235+
}
236+
// If text file is large and not supported by document API, fall through to file translation service
237+
}
238+
239+
// Check if it's a binary document (PDF, DOCX, etc.)
240+
if (this.documentTranslationService.isDocumentSupported(filePath)) {
241+
return this.translateDocument(filePath, options);
242+
}
243+
244+
// Single language translation using file translation service
241245
const translationOptions: {
242246
targetLang: Language;
243247
sourceLang?: Language;

src/services/batch-translation.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,17 @@ export class BatchTranslationService {
4747
options: { concurrency?: number } = {}
4848
) {
4949
this.fileTranslationService = fileTranslationService;
50-
this.concurrency = options.concurrency ?? 5;
50+
51+
// Validate concurrency parameter
52+
const concurrency = options.concurrency ?? 5;
53+
if (concurrency < 1) {
54+
throw new Error('Concurrency must be at least 1');
55+
}
56+
if (concurrency > 100) {
57+
throw new Error('Concurrency cannot exceed 100');
58+
}
59+
60+
this.concurrency = concurrency;
5161
}
5262

5363
/**

src/services/document-translation.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export class DocumentTranslationService {
5252
private readonly INITIAL_POLL_INTERVAL = 1000; // 1 second
5353
private readonly MAX_POLL_INTERVAL = 30000; // 30 seconds
5454
private readonly BACKOFF_MULTIPLIER = 1.5;
55+
private readonly MAX_POLL_ATTEMPTS = 180; // 90 minutes with 30s max interval
56+
private readonly TOTAL_TIMEOUT_MS = 90 * 60 * 1000; // 90 minutes total
5557

5658
constructor(client: DeepLClient) {
5759
this.client = client;
@@ -129,17 +131,29 @@ export class DocumentTranslationService {
129131
/**
130132
* Poll document status until translation is complete
131133
* Can be cancelled via abortSignal
134+
* Has maximum retry attempts and total timeout to prevent infinite loops
132135
*/
133136
private async pollDocumentStatus(
134137
handle: DocumentHandle,
135138
progressCallback?: ProgressCallback,
136139
abortSignal?: AbortSignal
137140
): Promise<DocumentStatus> {
138141
let pollInterval = this.INITIAL_POLL_INTERVAL;
142+
let attempts = 0;
143+
const startTime = Date.now();
139144
let status: DocumentStatus;
140145

141-
// eslint-disable-next-line no-constant-condition
142-
while (true) {
146+
while (attempts < this.MAX_POLL_ATTEMPTS) {
147+
attempts++;
148+
149+
// Check if total timeout exceeded
150+
const elapsed = Date.now() - startTime;
151+
if (elapsed > this.TOTAL_TIMEOUT_MS) {
152+
throw new Error(
153+
`Document translation timeout exceeded (${this.TOTAL_TIMEOUT_MS / 1000 / 60} minutes). The document may still be processing on DeepL servers.`
154+
);
155+
}
156+
143157
// Check if operation was cancelled
144158
if (abortSignal?.aborted) {
145159
throw new Error('Document translation cancelled');
@@ -159,7 +173,7 @@ export class DocumentTranslationService {
159173

160174
// Check if translation is complete
161175
if (status.status === 'done' || status.status === 'error') {
162-
break;
176+
return status;
163177
}
164178

165179
// Wait before next poll with exponential backoff
@@ -170,7 +184,11 @@ export class DocumentTranslationService {
170184
);
171185
}
172186

173-
return status;
187+
// Exceeded maximum attempts
188+
const totalElapsed = Date.now() - startTime;
189+
throw new Error(
190+
`Document translation exceeded maximum polling attempts (${this.MAX_POLL_ATTEMPTS} attempts over ${(totalElapsed / 1000 / 60).toFixed(1)} minutes). The document may still be processing on DeepL servers.`
191+
);
174192
}
175193

176194
/**

src/services/file-translation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,10 @@ export class FileTranslationService {
135135

136136
/**
137137
* Get list of supported file extensions
138+
* Returns a readonly array to avoid unnecessary copies
138139
*/
139-
getSupportedFileTypes(): string[] {
140-
return [...this.supportedExtensions];
140+
getSupportedFileTypes(): readonly string[] {
141+
return this.supportedExtensions;
141142
}
142143

143144
/**

0 commit comments

Comments
 (0)