Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions packages/cli/src/remoteInput/RemoteInputWatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,62 @@ describe('RemoteInputWatcher', () => {
expect(submitted).toEqual(['after-bad-line']);
});

it('reads commands written after the input file is truncated', async () => {

Copy link
Copy Markdown
Collaborator

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

Copy link
Copy Markdown
Contributor Author

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.

watcher = new RemoteInputWatcher(inputFile);
const submitted: string[] = [];
watcher.setSubmitFn((text) => {
submitted.push(text);
});

fs.appendFileSync(
inputFile,
JSON.stringify({ type: 'submit', text: 'before-truncate' }) + '\n',
);
await watcher.checkForNewInput();
const consumedSize = fs.statSync(inputFile).size;

const afterTruncate = 'after-truncate-with-a-longer-command';
fs.writeFileSync(
inputFile,
JSON.stringify({ type: 'submit', text: afterTruncate }) + '\n',
);
expect(fs.statSync(inputFile).size).toBeGreaterThan(consumedSize);
await watcher.checkForNewInput();

expect(submitted).toEqual(['before-truncate', afterTruncate]);
});

it('reads commands after truncation rewrites the file to the same size', async () => {
watcher = new RemoteInputWatcher(inputFile);
const submitted: string[] = [];
watcher.setSubmitFn((text) => {
submitted.push(text);
});

const beforeTruncate = 'before-truncate';
const afterTruncate = 'after--truncate';
const fixedMtime = new Date('2026-06-20T00:00:00.000Z');
expect(afterTruncate).toHaveLength(beforeTruncate.length);

fs.appendFileSync(
inputFile,
JSON.stringify({ type: 'submit', text: beforeTruncate }) + '\n',
);
fs.utimesSync(inputFile, fixedMtime, fixedMtime);
await watcher.checkForNewInput();
const consumedSize = fs.statSync(inputFile).size;

fs.writeFileSync(
inputFile,
JSON.stringify({ type: 'submit', text: afterTruncate }) + '\n',
);
fs.utimesSync(inputFile, fixedMtime, fixedMtime);
expect(fs.statSync(inputFile).size).toBe(consumedSize);
await watcher.checkForNewInput();

expect(submitted).toEqual([beforeTruncate, afterTruncate]);
});

it('stops watching after shutdown', async () => {
watcher = new RemoteInputWatcher(inputFile);
const submitted: string[] = [];
Expand Down
86 changes: 85 additions & 1 deletion packages/cli/src/remoteInput/RemoteInputWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }, () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -179,13 +208,68 @@ export class RemoteInputWatcher {
return new Promise<void>((resolve) => {
rl.on('close', () => {
this.bytesRead = currentSize;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 hashFilePrefix(currentSize) returns null (I/O error), this.bytesRead is advanced to currentSize (line 210) but this.consumedPrefixHash is not updated (null-guarded at line 211). This breaks the invariant that consumedPrefixHash === hash(bytesRead). On the next poll, hasConsumedPrefixChanged() sees consumedPrefixHash === null with bytesRead > 0, returns true (line 222-226, new fail-open behavior), triggering a full file re-read from offset 0. Every command in the file is re-submitted.

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 startWatching: if statSync succeeds but hashFilePrefix(this.bytesRead) returns null (line 108), bytesRead is set to stat.size but consumedPrefixHash stays null, setting up the same replay cycle.

Suggested change
this.bytesRead = currentSize;
// In readNewLines close handler, capture previous bytesRead:
const previousBytesRead = this.bytesRead;
this.bytesRead = currentSize;
if (nextConsumedPrefixHash !== null) {
this.consumedPrefixHash = nextConsumedPrefixHash;
} else {
// Hash failed — roll back to preserve invariant.
this.bytesRead = previousBytesRead;
}
// In hasConsumedPrefixChanged, return false on null hash:
if (currentHash === null) {
debugLogger.warn('RemoteInput: failed to hash consumed prefix');
return false;
}
// In startWatching, fall back to offset 0 on hash failure:
if (hashResult === null) {
this.bytesRead = 0;
this.consumedPrefixHash = null;
}

— 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;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] hashFilePrefix()'s catch block silently returns null on any I/O error without logging the failure reason. If hash-based detection degrades at runtime, there is zero observability into why — file-locking errors, permission changes, and transient faults all produce identical silent null returns. Adding a debugLogger.warn in the catch block would make this debuggable.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.


Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Error object logged to debug may include stack traces

catch (err) passes the raw error object to debugLogger.warn(...). The debug logger's formatArgs() converts Error instances to their .stack property, writing full stack traces to the on-disk debug log.

Suggested change
debugLogger.warn(
'RemoteInput: failed to hash file prefix:',
err instanceof Error ? err.message : String(err),
);

— 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;

Expand Down
Loading