Skip to content
Open
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
35 changes: 27 additions & 8 deletions packages/agent-core/src/utils/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { randomBytes } from 'node:crypto';
import { closeSync, fsyncSync, openSync } from 'node:fs';
import * as nodeFs from 'node:fs';
import { open, rename, unlink } from 'node:fs/promises';
import { lstat, open, realpath, rename, unlink } from 'node:fs/promises';
import { dirname } from 'pathe';

/**
Expand Down Expand Up @@ -51,6 +51,23 @@ export function syncDirSync(dirPath: string): void {
closeSync(fd);
}
}
/**
* Resolve a path that may be a symlink to the real underlying file path.
* If the path does not exist or is not a symlink, returns the original path.
*/
async function resolveSymlinkTarget(filePath: string): Promise<string> {
try {
const stats = await lstat(filePath);
if (stats.isSymbolicLink()) {
return await realpath(filePath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve dangling symlinks on first write

When filePath is a symlink whose target does not yet exist, lstat succeeds but realpath(filePath) throws ENOENT; the catch then returns the original symlink path, so the later rename(tmpPath, targetPath) replaces the symlink instead of creating/updating its target. This still breaks first-run config/dotfile symlinks to not-yet-created files—the preservation path this change is meant to fix—so resolve the link text rather than falling back to filePath on this ENOENT.

Useful? React with 👍 / 👎.

}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw error;
}
return filePath;
}

/**
* Write `content` to `filePath` atomically and durably:
* 1. Write content to `<filePath>.tmp`, fsync it, close it.
Expand All @@ -67,7 +84,8 @@ export async function writeFileAtomicDurable(
filePath: string,
content: string | Uint8Array,
): Promise<void> {
const tmpPath = filePath + '.tmp';
const targetPath = await resolveSymlinkTarget(filePath);
const tmpPath = targetPath + '.tmp';
let renamed = false;
try {
const fh = await open(tmpPath, 'w');
Expand All @@ -80,15 +98,15 @@ export async function writeFileAtomicDurable(
// Windows pre-unlink for MoveFileEx parity.
if (process.platform === 'win32') {
try {
await unlink(filePath);
await unlink(targetPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw error;
}
}
await rename(tmpPath, filePath);
await rename(tmpPath, targetPath);
renamed = true;
await syncDir(dirname(filePath));
await syncDir(dirname(targetPath));
} finally {
if (!renamed) {
// Best-effort cleanup of the `.tmp` file if we never got to the
Expand Down Expand Up @@ -151,8 +169,9 @@ export async function atomicWrite(
content: string | Uint8Array,
_syncOverride?: (fd: number) => Promise<void>,
): Promise<void> {
const targetPath = await resolveSymlinkTarget(filePath);
const hex = randomBytes(4).toString('hex');
const tmpPath = `${filePath}.tmp.${process.pid}.${hex}`;
const tmpPath = `${targetPath}.tmp.${process.pid}.${hex}`;
let renamed = false;
try {
const fh = await open(tmpPath, 'w');
Expand All @@ -167,13 +186,13 @@ export async function atomicWrite(
// before the rename turns this into the POSIX-style "replace" case.
if (process.platform === 'win32') {
try {
await unlink(filePath);
await unlink(targetPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw error;
}
}
await rename(tmpPath, filePath);
await rename(tmpPath, targetPath);
renamed = true;
} finally {
if (!renamed) {
Expand Down
98 changes: 98 additions & 0 deletions packages/agent-core/test/utils/fs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Tests for low-level fs utilities: atomicWrite, writeFileAtomicDurable.
*/

import { lstat, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'pathe';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { atomicWrite, writeFileAtomicDurable } from '../../src/utils/fs';

let rootDir: string;

beforeEach(async () => {
rootDir = join(
tmpdir(),
`kimi-fs-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
await mkdir(rootDir, { recursive: true });
});

afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});

describe('atomicWrite', () => {
it('writes content to a new file', async () => {
const path = join(rootDir, 'new-file.txt');
await atomicWrite(path, 'hello world');
expect(await readFile(path, 'utf-8')).toBe('hello world');
});

it('overwrites an existing file', async () => {
const path = join(rootDir, 'existing.txt');
await writeFile(path, 'old', 'utf-8');
await atomicWrite(path, 'new');
expect(await readFile(path, 'utf-8')).toBe('new');
});

it('preserves symlinks and updates the target', async () => {
const target = join(rootDir, 'real-config.toml');
const link = join(rootDir, 'config.toml');

await writeFile(target, 'old-value', 'utf-8');
await symlink(target, link);

await atomicWrite(link, 'new-value');

// Symlink must still be a symlink
const stats = await lstat(link);
expect(stats.isSymbolicLink()).toBe(true);

// Target must have new content
expect(await readFile(target, 'utf-8')).toBe('new-value');

// Reading through symlink must also give new content
expect(await readFile(link, 'utf-8')).toBe('new-value');
});

it('preserves relative symlinks', async () => {
const subdir = join(rootDir, 'sub');
await mkdir(subdir, { recursive: true });
const target = join(subdir, 'target.json');
const link = join(rootDir, 'link.json');

await writeFile(target, '{"old":true}', 'utf-8');
await symlink('sub/target.json', link);

await atomicWrite(link, '{"new":true}');

const stats = await lstat(link);
expect(stats.isSymbolicLink()).toBe(true);
expect(await readFile(target, 'utf-8')).toBe('{"new":true}');
});
});

describe('writeFileAtomicDurable', () => {
it('writes content to a new file', async () => {
const path = join(rootDir, 'durable.txt');
await writeFileAtomicDurable(path, 'durable content');
expect(await readFile(path, 'utf-8')).toBe('durable content');
});

it('preserves symlinks and updates the target', async () => {
const target = join(rootDir, 'real-data.json');
const link = join(rootDir, 'data.json');

await writeFile(target, '{"old":1}', 'utf-8');
await symlink(target, link);

await writeFileAtomicDurable(link, '{"new":2}');

const stats = await lstat(link);
expect(stats.isSymbolicLink()).toBe(true);
expect(await readFile(target, 'utf-8')).toBe('{"new":2}');
});
});
Loading