Skip to content

Commit a8fb97d

Browse files
xianjianlf2冼健聪wenshao
authored
fix(core): fail closed on zero inode file cache (QwenLM#8290)
* fix(core): fail closed on zero inode file cache * chore(core): address zero inode review suggestions * fix(core): reject zero inode transcript identity * fix(core): distinguish unverifiable file identity * fix(core): keep zero-inode fallout out of transcript pagination Addresses the review on QwenLM#8290. - reader: restore the plain inode comparison and record why the transcript reader does not need the fail-closed treatment. It only compares one session's path against itself and the cursor already carries a content-derived proof, whereas the cache and the lease compare identities that can belong to different files. Refusing zero there made readPage reject the very cursor it had just issued, so pagination died past page one. - lease: probe the transcript filesystem during acquire so a brand-new session on such a filesystem fails up front instead of going quiet at the first append. - share hasVerifiableInode() across the three call sites and handle the bigint stat shape. - cover the PRIOR_READ_VERIFICATION_FAILED branches in edit, write-file and notebook-edit. --------- Co-authored-by: 冼健聪 <mark.xian@evenrealities.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
1 parent a3a840b commit a8fb97d

12 files changed

Lines changed: 562 additions & 52 deletions

packages/core/src/services/fileReadCache.test.ts

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@ describe('FileReadCache', () => {
3838
const b = FileReadCache.inodeKey(makeStats({ dev: 2, ino: 1 }));
3939
expect(a).not.toBe(b);
4040
});
41+
42+
it('treats ino 0 as unverifiable identity', () => {
43+
expect(FileReadCache.hasVerifiableIdentity(makeStats({ ino: 0 }))).toBe(
44+
false,
45+
);
46+
expect(FileReadCache.hasVerifiableIdentity(makeStats({ ino: 1 }))).toBe(
47+
true,
48+
);
49+
});
50+
51+
it('treats a bigint ino 0 as unverifiable identity', () => {
52+
// `stat(..., { bigint: true })` is used elsewhere in the repo, and
53+
// `0n !== 0` is true, so the check must not be a raw `!==`.
54+
const bigintStats = makeStats({
55+
ino: 0n as unknown as number,
56+
});
57+
expect(FileReadCache.hasVerifiableIdentity(bigintStats)).toBe(false);
58+
});
4159
});
4260

4361
describe('check', () => {
@@ -87,6 +105,19 @@ describe('FileReadCache', () => {
87105
expect(cache.check(makeStats({ ino: 200 })).state).toBe('unknown');
88106
});
89107

108+
it('returns unverifiable for ino 0 even after a read was recorded', () => {
109+
const cache = new FileReadCache();
110+
const stats = makeStats({ dev: 7, ino: 0 });
111+
const entry = cache.recordRead('/x/foo.ts', stats, {
112+
full: true,
113+
cacheable: true,
114+
});
115+
116+
expect(entry.inodeKey).toBe('7:0');
117+
expect(cache.size()).toBe(0);
118+
expect(cache.check(stats).state).toBe('unverifiable');
119+
});
120+
90121
it('attaches the entry on fresh and stale results', () => {
91122
const cache = new FileReadCache();
92123
cache.recordRead('/x/foo.ts', makeStats(), {
@@ -125,6 +156,33 @@ describe('FileReadCache', () => {
125156
expect(entry.lastReadAt).toBe(Date.now());
126157
});
127158

159+
it('does not let ino 0 reads collide across paths', () => {
160+
const cache = new FileReadCache();
161+
const first = makeStats({ dev: 9, ino: 0, size: 10 });
162+
const second = makeStats({ dev: 9, ino: 0, size: 20 });
163+
164+
cache.recordRead('/x/a.ts', first, { full: true, cacheable: true });
165+
cache.recordRead('/x/b.ts', second, { full: true, cacheable: true });
166+
167+
expect(cache.size()).toBe(0);
168+
expect(cache.check(first).state).toBe('unverifiable');
169+
expect(cache.check(second).state).toBe('unverifiable');
170+
});
171+
172+
it('returns a detached entry for an ino 0 read', () => {
173+
const cache = new FileReadCache();
174+
const stats = makeStats({ dev: 9, ino: 0 });
175+
176+
const entry = cache.recordRead('/x/a.ts', stats, {
177+
full: true,
178+
cacheable: true,
179+
});
180+
entry.readResidentInHistory = true;
181+
182+
expect(cache.size()).toBe(0);
183+
expect(cache.check(stats).state).toBe('unverifiable');
184+
});
185+
128186
it('preserves full vs ranged read distinction', () => {
129187
const cache = new FileReadCache();
130188
const stats = makeStats();
@@ -262,6 +320,17 @@ describe('FileReadCache', () => {
262320
expect(entry.lastWriteAt).toBe(Date.now());
263321
});
264322

323+
it('does not cache writes with ino 0', () => {
324+
const cache = new FileReadCache();
325+
const stats = makeStats({ dev: 7, ino: 0 });
326+
327+
const entry = cache.recordWrite('/x/foo.ts', stats);
328+
329+
expect(entry.lastWriteAt).toBe(Date.now());
330+
expect(cache.size()).toBe(0);
331+
expect(cache.check(stats).state).toBe('unverifiable');
332+
});
333+
265334
it('seeds read metadata when recording a write on a brand-new entry', () => {
266335
// The model authored the bytes it just wrote — for the purposes
267336
// of prior-read enforcement on the *next* Edit, that counts as
@@ -679,25 +748,25 @@ describe('FileReadCache', () => {
679748
it('evicts the oldest entry when the cache exceeds MAX_ENTRIES', () => {
680749
// Fill cache to capacity (MAX_ENTRIES = 4096).
681750
const cache = new FileReadCache();
682-
for (let i = 0; i < 4096; i++) {
751+
for (let i = 1; i <= 4096; i++) {
683752
cache.recordRead(`/x/file-${i}.ts`, makeStats({ ino: i }), {
684753
full: true,
685754
cacheable: true,
686755
});
687756
}
688757
expect(cache.size()).toBe(4096);
689758

690-
// The 4097th write triggers eviction of the oldest (ino=0).
691-
cache.recordWrite('/x/file-new.ts', makeStats({ ino: 4096 }));
759+
// The 4097th write triggers eviction of the oldest (ino=1).
760+
cache.recordWrite('/x/file-new.ts', makeStats({ ino: 4097 }));
692761
expect(cache.size()).toBeLessThanOrEqual(4096);
693-
expect(cache.check(makeStats({ ino: 0 })).state).toBe('unknown');
694-
expect(cache.check(makeStats({ ino: 4096 })).state).toBe('fresh');
762+
expect(cache.check(makeStats({ ino: 1 })).state).toBe('unknown');
763+
expect(cache.check(makeStats({ ino: 4097 })).state).toBe('fresh');
695764
});
696765

697766
it('keeps size at MAX_ENTRIES after multiple overflows', () => {
698767
const cache = new FileReadCache();
699768
// Add MAX_ENTRIES + 100 distinct inodes.
700-
for (let i = 0; i < 4196; i++) {
769+
for (let i = 1; i <= 4196; i++) {
701770
cache.recordRead(`/x/file-${i}.ts`, makeStats({ ino: i }), {
702771
full: true,
703772
cacheable: true,
@@ -709,17 +778,17 @@ describe('FileReadCache', () => {
709778
it('should have bumped entries survive eviction', () => {
710779
const cache = new FileReadCache();
711780
// Fill to capacity.
712-
for (let i = 0; i < 4096; i++) {
781+
for (let i = 1; i <= 4096; i++) {
713782
cache.recordRead(`/x/file-${i}.ts`, makeStats({ ino: i }), {
714783
full: true,
715784
cacheable: true,
716785
});
717786
}
718787

719-
// Frequently update ino=0 — after bump lands this moves it to the
788+
// Frequently update ino=1 — after bump lands this moves it to the
720789
// back of the eviction queue.
721790
for (let i = 0; i < 10; i++) {
722-
cache.recordRead('/x/file-0.ts', makeStats({ ino: 0 }), {
791+
cache.recordRead('/x/file-1.ts', makeStats({ ino: 1 }), {
723792
full: true,
724793
cacheable: true,
725794
});
@@ -734,7 +803,7 @@ describe('FileReadCache', () => {
734803
}
735804

736805
expect(cache.size()).toBeLessThanOrEqual(4096);
737-
expect(cache.check(makeStats({ ino: 0 })).state).not.toBe('unknown');
806+
expect(cache.check(makeStats({ ino: 1 })).state).not.toBe('unknown');
738807
});
739808
});
740809

@@ -812,7 +881,7 @@ describe('FileReadCache', () => {
812881
vi.setSystemTime(now);
813882

814883
// 3 recent entries
815-
for (let i = 0; i < 3; i++) {
884+
for (let i = 1; i <= 3; i++) {
816885
cache.recordRead(`/x/recent-${i}.ts`, makeStats({ ino: i }), {
817886
full: true,
818887
cacheable: true,

packages/core/src/services/fileReadCache.ts

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import type { Stats } from 'node:fs';
88
import { resolve as resolvePath } from 'node:path';
9+
import { hasVerifiableInode } from '../utils/file-identity.js';
910

1011
/**
1112
* Session-scoped cache that tracks which files the model has Read or
@@ -25,11 +26,23 @@ import { resolve as resolvePath } from 'node:path';
2526
* case-insensitive filesystems all collapse onto the same entry, which
2627
* is what we want — the cache is reasoning about *files*, not strings.
2728
*
28-
* Platform note: on Windows, `Stats.ino` is documented as not guaranteed
29-
* unique (Node returns it from `_BY_HANDLE_FILE_INFORMATION.nFileIndex`,
30-
* which can collide across volumes and ReFS). Callers that target
31-
* Windows should consider falling back to a path-based key; the POSIX
32-
* platforms qwen-code primarily runs on (macOS / Linux) are unaffected.
29+
* Platform note: when `Stats.ino` is `0` (seen on FAT/exFAT and some
30+
* SMB-style filesystems), inode identity is unverifiable. The cache
31+
* deliberately does not store those reads/writes, so later mutation
32+
* checks fail closed instead of treating unrelated files as the same
33+
* `dev:0` entry. On Windows, non-zero `ino` values from `nFileIndex`
34+
* can also collide across volumes and on ReFS; a path-based key
35+
* fallback for that case is not yet implemented.
36+
*
37+
* Keying on the resolved path when `ino === 0` would keep Edit /
38+
* WriteFile usable on those filesystems while still giving distinct
39+
* paths distinct entries, and it is the obvious follow-up if anyone
40+
* reports the loss. It is deliberately not done here: a path key is
41+
* strictly weaker (an in-place replacement that preserves mtime and
42+
* size reads as the same file, and FAT's 2-second mtime granularity
43+
* makes that collision cheap), so it trades a silent wrong edit for
44+
* an availability win. This cache chose the honest failure first;
45+
* availability can be added later behind an explicit decision.
3346
*
3447
* Lifecycle: one instance is created per `Config` via the field
3548
* initializer, so any code that constructs its own Config — notably
@@ -131,6 +144,7 @@ export interface FileReadEntry {
131144
export type FileReadCheckResult =
132145
| { state: 'fresh'; entry: FileReadEntry }
133146
| { state: 'stale'; entry: FileReadEntry }
147+
| { state: 'unverifiable' }
134148
| { state: 'unknown' };
135149

136150
export class FileReadCache {
@@ -142,6 +156,11 @@ export class FileReadCache {
142156
return `${stats.dev}:${stats.ino}`;
143157
}
144158

159+
/** See {@link hasVerifiableInode}. */
160+
static hasVerifiableIdentity(stats: Stats): boolean {
161+
return hasVerifiableInode(stats.ino);
162+
}
163+
145164
/**
146165
* Record a successful Read of `absPath`.
147166
*
@@ -180,12 +199,26 @@ export class FileReadCache {
180199
* The fast-path `file_unchanged` check still gates on the
181200
* incoming request's own `isFullRead` (in `read-file.ts`), so a
182201
* partial read does not get a placeholder it shouldn't.
202+
*
203+
* When `stats.ino` is `0` the read is not stored and the returned
204+
* entry is **detached**: it describes this read for the immediate
205+
* caller, but it is not in the map, so mutating it has no effect
206+
* and a later {@link check} still reports `unverifiable`.
183207
*/
184208
recordRead(
185209
absPath: string,
186210
stats: Stats,
187211
opts: { full: boolean; cacheable: boolean },
188212
): FileReadEntry {
213+
if (!FileReadCache.hasVerifiableIdentity(stats)) {
214+
const entry = FileReadCache.createEntry(absPath, stats);
215+
entry.lastReadAt = Date.now();
216+
entry.readResidentInHistory = opts.full;
217+
entry.lastReadWasFull = opts.full;
218+
entry.lastReadCacheable = opts.cacheable;
219+
return entry;
220+
}
221+
189222
const key = FileReadCache.inodeKey(stats);
190223
const existing = this.byInode.get(key);
191224
const sameFingerprint =
@@ -238,13 +271,18 @@ export class FileReadCache {
238271
* the default `cacheable: true`; structured writers such as notebook cell
239272
* editors can set `cacheable: false` so regular Edit / WriteFile still
240273
* reject the file as a non-text payload.
274+
*
275+
* As with {@link recordRead}, an `ino === 0` write returns a
276+
* **detached** entry that was never added to the map.
241277
*/
242278
recordWrite(
243279
absPath: string,
244280
stats: Stats,
245281
opts: { cacheable?: boolean } = {},
246282
): FileReadEntry {
247-
const entry = this.upsert(absPath, stats);
283+
const entry = FileReadCache.hasVerifiableIdentity(stats)
284+
? this.upsert(absPath, stats)
285+
: FileReadCache.createEntry(absPath, stats);
248286
const now = Date.now();
249287
entry.lastWriteAt = now;
250288
entry.lastReadAt = now;
@@ -259,6 +297,8 @@ export class FileReadCache {
259297
/**
260298
* Compare the cached fingerprint against `stats` for the same inode.
261299
*
300+
* - `unverifiable` — the filesystem reported `ino === 0`, so the
301+
* file identity cannot be safely compared or cached.
262302
* - `unknown` — no entry. The file has never been Read or written in
263303
* this session.
264304
* - `stale` — entry exists but mtime or size differs. The file has
@@ -273,6 +313,10 @@ export class FileReadCache {
273313
* `0 occurrences` failure mode, which prompts the model to re-read.
274314
*/
275315
check(stats: Stats): FileReadCheckResult {
316+
if (!FileReadCache.hasVerifiableIdentity(stats)) {
317+
return { state: 'unverifiable' };
318+
}
319+
276320
const entry = this.byInode.get(FileReadCache.inodeKey(stats));
277321
if (!entry) return { state: 'unknown' };
278322
if (entry.mtimeMs !== stats.mtimeMs || entry.sizeBytes !== stats.size) {
@@ -301,6 +345,10 @@ export class FileReadCache {
301345
* fall back to {@link clear}.
302346
*/
303347
markReadEvictedFromHistory(stats: Stats): boolean {
348+
if (!FileReadCache.hasVerifiableIdentity(stats)) {
349+
return false;
350+
}
351+
304352
const entry = this.byInode.get(FileReadCache.inodeKey(stats));
305353
if (entry) {
306354
entry.readResidentInHistory = false;
@@ -311,6 +359,10 @@ export class FileReadCache {
311359

312360
/** Remove the entry for the given Stats, if any. */
313361
invalidate(stats: Stats): void {
362+
if (!FileReadCache.hasVerifiableIdentity(stats)) {
363+
return;
364+
}
365+
314366
this.byInode.delete(FileReadCache.inodeKey(stats));
315367
}
316368

@@ -397,16 +449,20 @@ export class FileReadCache {
397449
this.byInode.delete(oldestKey);
398450
}
399451
}
400-
const entry: FileReadEntry = {
401-
inodeKey: key,
452+
const entry = FileReadCache.createEntry(absPath, stats);
453+
this.byInode.set(key, entry);
454+
return entry;
455+
}
456+
457+
private static createEntry(absPath: string, stats: Stats): FileReadEntry {
458+
return {
459+
inodeKey: FileReadCache.inodeKey(stats),
402460
realPath: absPath,
403461
mtimeMs: stats.mtimeMs,
404462
sizeBytes: stats.size,
405463
lastReadWasFull: false,
406464
lastReadCacheable: false,
407465
readResidentInHistory: false,
408466
};
409-
this.byInode.set(key, entry);
410-
return entry;
411467
}
412468
}

0 commit comments

Comments
 (0)