-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(cli): handle truncated remote input files #5473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,7 +4,16 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| * SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import { createReadStream, watchFile, unwatchFile, statSync } from 'node:fs'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { createHash } from 'node:crypto'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||
| closeSync, | ||||||||||||||||||||||||||||||||||||||||||||||
| createReadStream, | ||||||||||||||||||||||||||||||||||||||||||||||
| openSync, | ||||||||||||||||||||||||||||||||||||||||||||||
| readSync, | ||||||||||||||||||||||||||||||||||||||||||||||
| statSync, | ||||||||||||||||||||||||||||||||||||||||||||||
| unwatchFile, | ||||||||||||||||||||||||||||||||||||||||||||||
| watchFile, | ||||||||||||||||||||||||||||||||||||||||||||||
| } from 'node:fs'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { createInterface } from 'node:readline'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { createDebugLogger } from '@qwen-code/qwen-code-core'; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -51,6 +60,7 @@ export class RemoteInputWatcher { | |||||||||||||||||||||||||||||||||||||||||||||
| private processing = false; | ||||||||||||||||||||||||||||||||||||||||||||||
| private active = true; | ||||||||||||||||||||||||||||||||||||||||||||||
| private bytesRead = 0; | ||||||||||||||||||||||||||||||||||||||||||||||
| private consumedPrefixHash: string | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||
| private reading = false; | ||||||||||||||||||||||||||||||||||||||||||||||
| private filePath: string; | ||||||||||||||||||||||||||||||||||||||||||||||
| private retryTimer: ReturnType<typeof setTimeout> | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -95,8 +105,10 @@ export class RemoteInputWatcher { | |||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||
| const stat = statSync(this.filePath); | ||||||||||||||||||||||||||||||||||||||||||||||
| this.bytesRead = stat.size; | ||||||||||||||||||||||||||||||||||||||||||||||
| this.consumedPrefixHash = this.hashFilePrefix(this.bytesRead); | ||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||
| this.bytesRead = 0; | ||||||||||||||||||||||||||||||||||||||||||||||
| this.consumedPrefixHash = null; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| watchFile(this.filePath, { interval: this.pollIntervalMs }, () => { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -128,8 +140,25 @@ export class RemoteInputWatcher { | |||||||||||||||||||||||||||||||||||||||||||||
| return Promise.resolve(); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Size alone misses truncate+rewrite that lands at the same or a larger | ||||||||||||||||||||||||||||||||||||||||||||||
| // size. Append-only writes preserve the consumed prefix hash; rewrites do not. | ||||||||||||||||||||||||||||||||||||||||||||||
| if (currentSize < this.bytesRead) { | ||||||||||||||||||||||||||||||||||||||||||||||
| debugLogger.debug( | ||||||||||||||||||||||||||||||||||||||||||||||
| 'RemoteInput: input file shrank, resetting read offset', | ||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||
| this.bytesRead = 0; | ||||||||||||||||||||||||||||||||||||||||||||||
| this.consumedPrefixHash = null; | ||||||||||||||||||||||||||||||||||||||||||||||
| } else if (this.hasConsumedPrefixChanged()) { | ||||||||||||||||||||||||||||||||||||||||||||||
| debugLogger.debug( | ||||||||||||||||||||||||||||||||||||||||||||||
| 'RemoteInput: input file prefix changed, resetting read offset', | ||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||
| this.bytesRead = 0; | ||||||||||||||||||||||||||||||||||||||||||||||
| this.consumedPrefixHash = null; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (currentSize <= this.bytesRead) return Promise.resolve(); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const nextConsumedPrefixHash = this.hashFilePrefix(currentSize); | ||||||||||||||||||||||||||||||||||||||||||||||
| this.reading = true; | ||||||||||||||||||||||||||||||||||||||||||||||
| const stream = createReadStream(this.filePath, { | ||||||||||||||||||||||||||||||||||||||||||||||
| start: this.bytesRead, | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -179,13 +208,68 @@ export class RemoteInputWatcher { | |||||||||||||||||||||||||||||||||||||||||||||
| return new Promise<void>((resolve) => { | ||||||||||||||||||||||||||||||||||||||||||||||
| rl.on('close', () => { | ||||||||||||||||||||||||||||||||||||||||||||||
| this.bytesRead = currentSize; | ||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Command replay on hash failure — state corruption causes false truncation detection When If the I/O error persists (NFS hiccup, disk pressure, permission change), each poll cycle (500ms) re-submits all commands — an unbounded replay loop. The same precondition exists in
Suggested change
— DeepSeek/deepseek-v4-pro via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||
| if (nextConsumedPrefixHash !== null) { | ||||||||||||||||||||||||||||||||||||||||||||||
| this.consumedPrefixHash = nextConsumedPrefixHash; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| this.reading = false; | ||||||||||||||||||||||||||||||||||||||||||||||
| this.processQueue(); | ||||||||||||||||||||||||||||||||||||||||||||||
| resolve(); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private hasConsumedPrefixChanged(): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (this.bytesRead === 0) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| if (this.consumedPrefixHash === null) { | ||||||||||||||||||||||||||||||||||||||||||||||
| debugLogger.warn( | ||||||||||||||||||||||||||||||||||||||||||||||
| 'RemoteInput: missing consumed prefix hash, resetting read offset', | ||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const currentHash = this.hashFilePrefix(this.bytesRead); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (currentHash === null) { | ||||||||||||||||||||||||||||||||||||||||||||||
| debugLogger.warn( | ||||||||||||||||||||||||||||||||||||||||||||||
| 'RemoteInput: failed to hash consumed prefix, resetting read offset', | ||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| return currentHash !== this.consumedPrefixHash; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private hashFilePrefix(size: number): string | null { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (size <= 0) return null; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| let fd: number | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||
| fd = openSync(this.filePath, 'r'); | ||||||||||||||||||||||||||||||||||||||||||||||
| const hash = createHash('sha256'); | ||||||||||||||||||||||||||||||||||||||||||||||
| const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, size)); | ||||||||||||||||||||||||||||||||||||||||||||||
| let remaining = size; | ||||||||||||||||||||||||||||||||||||||||||||||
| let position = 0; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| while (remaining > 0) { | ||||||||||||||||||||||||||||||||||||||||||||||
| const bytesToRead = Math.min(buffer.length, remaining); | ||||||||||||||||||||||||||||||||||||||||||||||
| const bytesRead = readSync(fd, buffer, 0, bytesToRead, position); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (bytesRead <= 0) return null; | ||||||||||||||||||||||||||||||||||||||||||||||
| hash.update(buffer.subarray(0, bytesRead)); | ||||||||||||||||||||||||||||||||||||||||||||||
| remaining -= bytesRead; | ||||||||||||||||||||||||||||||||||||||||||||||
| position += bytesRead; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] — DeepSeek/deepseek-v4-pro via Qwen Code /review
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. added warning logs for hash failures as well, so this is visible instead of silently degrading. |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Error object logged to debug may include stack traces
Suggested change
— DeepSeek/deepseek-v4-pro via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||
| return hash.digest('base64'); | ||||||||||||||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||||||||||||||
| debugLogger.warn('RemoteInput: failed to hash file prefix:', err); | ||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (fd !== null) { | ||||||||||||||||||||||||||||||||||||||||||||||
| closeSync(fd); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private async processQueue(): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (this.processing || !this.submitFn || this.queue.length === 0) return; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The new test covers truncate-and-rewrite with a larger file, but not with a file of equal size. The equal-size case is where the old size-only check was most blind (it would skip the new content entirely), and where the hash-based fix is most critical. Adding a same-size test would catch a future regression where the hash logic is removed but the existing larger-size test still passes.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
added a same-size truncate/rewrite regression test. it uses two commands with equal JSONL length and verifies the rewritten command is still read.