Skip to content

Commit ad37c11

Browse files
authored
Merge pull request #162 from han-dreamer/fix/config-write-exdev
fix: handle cross-device config writes
2 parents 7324d50 + 8de2502 commit ad37c11

2 files changed

Lines changed: 138 additions & 6 deletions

File tree

src/config/loader.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
1-
import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
1+
import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs';
22
import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from './schema';
33
import { ensureConfigDir, getConfigPath } from './paths';
44
import { detectOutputFormat, type OutputFormat } from '../output/formatter';
55
import { CLIError } from '../errors/base';
66
import { ExitCode } from '../errors/codes';
77
import type { GlobalFlags } from '../types/flags';
88

9+
interface RenameWithFallbackOps {
10+
rename: (from: string, to: string) => void;
11+
copy: (from: string, to: string) => void;
12+
unlink: (path: string) => void;
13+
}
14+
15+
function isCrossDeviceError(err: unknown): err is Error & { code: 'EXDEV' } {
16+
return err instanceof Error
17+
&& 'code' in err
18+
&& (err as { code?: string }).code === 'EXDEV';
19+
}
20+
21+
export function renameWithCrossDeviceFallback(
22+
from: string,
23+
to: string,
24+
ops: RenameWithFallbackOps = {
25+
rename: renameSync,
26+
copy: copyFileSync,
27+
unlink: unlinkSync,
28+
},
29+
): void {
30+
try {
31+
ops.rename(from, to);
32+
} catch (err) {
33+
if (!isCrossDeviceError(err)) throw err;
34+
ops.copy(from, to);
35+
ops.unlink(from);
36+
}
37+
}
38+
939
export function readConfigFile(): ConfigFile {
1040
const path = getConfigPath();
1141
if (!existsSync(path)) return {};
@@ -20,12 +50,15 @@ export function readConfigFile(): ConfigFile {
2050
}
2151
}
2252

23-
export async function writeConfigFile(data: Record<string, unknown>): Promise<void> {
53+
export async function writeConfigFile(
54+
data: Record<string, unknown>,
55+
renameOps?: RenameWithFallbackOps,
56+
): Promise<void> {
2457
await ensureConfigDir();
2558
const path = getConfigPath();
2659
const tmp = path + '.tmp';
2760
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
28-
renameSync(tmp, path);
61+
renameWithCrossDeviceFallback(tmp, path, renameOps);
2962
}
3063

3164
export function loadConfig(flags: GlobalFlags): Config {

test/config/loader.test.ts

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2-
import { mkdirSync, rmSync } from 'fs';
1+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
2+
import { copyFileSync, mkdirSync, readFileSync, rmSync, unlinkSync } from 'fs';
33
import { join } from 'path';
44
import { tmpdir } from 'os';
5-
import { loadConfig } from '../../src/config/loader';
5+
import { loadConfig, renameWithCrossDeviceFallback, writeConfigFile } from '../../src/config/loader';
66
import { CLIError } from '../../src/errors/base';
77
import type { GlobalFlags } from '../../src/types/flags';
88

@@ -57,3 +57,102 @@ describe('loadConfig', () => {
5757
expect(config.baseUrl).toBe('https://api.minimaxi.com');
5858
});
5959
});
60+
61+
describe('writeConfigFile', () => {
62+
const testDir = join(tmpdir(), `mmx-config-write-test-${Date.now()}`);
63+
const originalConfigDir = process.env.MMX_CONFIG_DIR;
64+
65+
beforeEach(() => {
66+
process.env.MMX_CONFIG_DIR = testDir;
67+
mkdirSync(testDir, { recursive: true });
68+
});
69+
70+
afterEach(() => {
71+
if (originalConfigDir === undefined) delete process.env.MMX_CONFIG_DIR;
72+
else process.env.MMX_CONFIG_DIR = originalConfigDir;
73+
rmSync(testDir, { recursive: true, force: true });
74+
mock.restore();
75+
});
76+
77+
it('falls back to copy and unlink when rename crosses devices', async () => {
78+
const calls: string[] = [];
79+
const renameMock = mock((from: string, _to: string) => {
80+
calls.push(`rename:${from}`);
81+
const err = new Error('cross-device link not permitted') as NodeJS.ErrnoException;
82+
err.code = 'EXDEV';
83+
err.path = from;
84+
throw err;
85+
});
86+
const copyMock = mock((from: string, _to: string) => {
87+
calls.push(`copy:${from}`);
88+
});
89+
const unlinkMock = mock((path: string) => {
90+
calls.push(`unlink:${path}`);
91+
});
92+
93+
renameWithCrossDeviceFallback('config.json.tmp', 'config.json', {
94+
rename: renameMock,
95+
copy: copyMock,
96+
unlink: unlinkMock,
97+
});
98+
99+
expect(renameMock).toHaveBeenCalledTimes(1);
100+
expect(copyMock).toHaveBeenCalledTimes(1);
101+
expect(unlinkMock).toHaveBeenCalledTimes(1);
102+
expect(calls).toEqual([
103+
'rename:config.json.tmp',
104+
'copy:config.json.tmp',
105+
'unlink:config.json.tmp',
106+
]);
107+
});
108+
109+
it('writes the config file when the final rename crosses devices', async () => {
110+
const renameMock = mock((from: string, _to: string) => {
111+
const err = new Error('cross-device link not permitted') as Error & {
112+
code?: string;
113+
path?: string;
114+
};
115+
err.code = 'EXDEV';
116+
err.path = from;
117+
throw err;
118+
});
119+
const copyMock = mock((from: string, to: string) => copyFileSync(from, to));
120+
const unlinkMock = mock((path: string) => unlinkSync(path));
121+
122+
await writeConfigFile({ region: 'cn', output: 'json' }, {
123+
rename: renameMock,
124+
copy: copyMock,
125+
unlink: unlinkMock,
126+
});
127+
128+
const configPath = join(testDir, 'config.json');
129+
const parsed = JSON.parse(readFileSync(configPath, 'utf-8'));
130+
131+
expect(renameMock).toHaveBeenCalledTimes(1);
132+
expect(copyMock).toHaveBeenCalledTimes(1);
133+
expect(unlinkMock).toHaveBeenCalledTimes(1);
134+
expect(parsed.region).toBe('cn');
135+
expect(parsed.output).toBe('json');
136+
});
137+
138+
it('rethrows non-EXDEV rename errors', () => {
139+
const renameMock = mock(() => {
140+
const err = new Error('permission denied') as NodeJS.ErrnoException;
141+
err.code = 'EACCES';
142+
throw err;
143+
});
144+
145+
expect(() => renameWithCrossDeviceFallback('config.json.tmp', 'config.json', {
146+
rename: renameMock,
147+
copy: mock(() => {}),
148+
unlink: mock(() => {}),
149+
})).toThrow('permission denied');
150+
});
151+
152+
it('uses atomic rename on the normal path', async () => {
153+
await writeConfigFile({ output: 'json' });
154+
155+
const configPath = join(testDir, 'config.json');
156+
expect(JSON.parse(readFileSync(configPath, 'utf-8')).output).toBe('json');
157+
});
158+
});

0 commit comments

Comments
 (0)