mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(channels): keep the cursor write alive when the temp file mode cannot be tightened (#16257)
* fix(channels): keep the cursor write alive when the temp file mode cannot be tightened Mounts without Unix permissions (SMB/CIFS, exFAT, some FUSE) accept the write but refuse chmod, so the unguarded temp-file chmod made every save throw before the rename and the cursor was never persisted. Guard it like the directory chmod and pin the behaviour with an EPERM test. Also seed the scope directory at 0o755 in the permissions test so it proves the tightening, and pin that an empty scoped cursor starts fresh instead of falling back to the legacy global file. * test(channels): assert the injected chmod fault fired in the cursor guard test Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
1 parent
4c8ebeab4d
commit
47d41dbf71
2 files changed
+71
-2
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import * as nodeFs from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { PollingChannelBase } from './PollingChannelBase.js';
|
||||
@@ -19,6 +21,13 @@ import type { ChannelConfig } from './types.js';
|
||||
|
||||
const testHome = mkdtempSync(join(tmpdir(), 'poll-test-'));
|
||||
|
||||
// The mock namespace (unlike the raw ESM builtin) is configurable, so a test
|
||||
// can spyOn chmodSync to inject a permission fault and restore it afterwards.
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual };
|
||||
});
|
||||
|
||||
vi.mock('./paths.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./paths.js')>();
|
||||
return { ...actual, getGlobalQwenDir: () => testHome };
|
||||
@@ -208,6 +217,17 @@ describe('PollingChannelBase', () => {
|
||||
expect(poller.cursor.count).toBe(2);
|
||||
});
|
||||
|
||||
it('does not fall back to a legacy cursor when the scoped file is empty', () => {
|
||||
writeFileSync(
|
||||
legacyCursorFile('empty-scoped'),
|
||||
JSON.stringify({ ts: '2026-06-01T00:00:00.000Z', count: 9 }),
|
||||
'utf-8',
|
||||
);
|
||||
writeFileSync(cursorFile('empty-scoped'), '', 'utf-8');
|
||||
const poller = new TestPoller('empty-scoped', makeConfig(), makeBridge());
|
||||
expect(poller.cursor).toEqual({ ts: '2026-01-01T00:00:00.000Z', count: 0 });
|
||||
});
|
||||
|
||||
it('does not fall back to a legacy cursor when the scoped file is corrupt', () => {
|
||||
writeFileSync(
|
||||
legacyCursorFile('corrupt-scoped'),
|
||||
@@ -227,13 +247,57 @@ describe('PollingChannelBase', () => {
|
||||
{ ...makeConfig(), cwd: '/tmp/ws-modes' },
|
||||
makeBridge(),
|
||||
);
|
||||
poller.save();
|
||||
const file = cursorFile('modes', '/tmp/ws-modes');
|
||||
// A scope directory left at 0o755 by an older build must be tightened.
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
chmodSync(dirname(file), 0o755);
|
||||
poller.save();
|
||||
expect(statSync(dirname(file)).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(file).mode & 0o777).toBe(0o600);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'still publishes the cursor when the temp file mode cannot be tightened',
|
||||
() => {
|
||||
const poller = new TestPoller(
|
||||
'chmod-denied',
|
||||
{ ...makeConfig(), cwd: '/tmp/ws-chmod-denied' },
|
||||
makeBridge(),
|
||||
);
|
||||
poller.cursor = { ts: '2026-07-03T00:00:00.000Z', count: 3 };
|
||||
let faults = 0;
|
||||
const realChmod = nodeFs.chmodSync;
|
||||
const spy = vi
|
||||
.spyOn(nodeFs, 'chmodSync')
|
||||
.mockImplementation((p, mode) => {
|
||||
if (String(p).endsWith('.tmp')) {
|
||||
faults++;
|
||||
const err = new Error('EPERM: operation not permitted');
|
||||
(err as NodeJS.ErrnoException).code = 'EPERM';
|
||||
throw err;
|
||||
}
|
||||
realChmod(p, mode);
|
||||
});
|
||||
try {
|
||||
poller.save();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
// A renamed temp file or a chmod call the spy cannot see would take the
|
||||
// success path and still publish the cursor, hiding a reverted guard.
|
||||
expect(faults).toBeGreaterThan(0);
|
||||
expect(
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
cursorFile('chmod-denied', '/tmp/ws-chmod-denied'),
|
||||
'utf-8',
|
||||
),
|
||||
),
|
||||
).toEqual({ ts: '2026-07-03T00:00:00.000Z', count: 3 });
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'tightens a pre-existing temp file before it becomes the cursor',
|
||||
() => {
|
||||
|
||||
@@ -125,7 +125,12 @@ export abstract class PollingChannelBase<Cursor> extends ChannelBase {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
chmodSync(tmp, 0o600);
|
||||
try {
|
||||
chmodSync(tmp, 0o600);
|
||||
} catch {
|
||||
// A mode we cannot tighten (a mount without Unix permissions) must
|
||||
// not block the cursor write; the rename below still publishes it.
|
||||
}
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user