mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(code): make edit-block writes crash-safe via atomic rename (#1721)
applyEditBlock previously truncated the target file in-place before writing the new bytes. A crash between truncate and writeAll could leave the file empty, partially rewritten, or NUL-padded — high blast radius since model-generated edits all flow through this path. Switch to the standard atomic-replace pattern: 1. Resolve realpath so edits through a symlink modify the referent. 2. Write the new bytes to a sibling .tmp file with wx (exclusive create). 3. chmod to preserve the original mode under umask. 4. fsync the temp fd, close, then rename onto the target. 5. Best-effort fsync the parent directory. 6. On any error, unlink the temp file so we don't leak .tmp clutter. Also fixes a latent short-write bug in the previous loop: `if (n <= 0) break;` would silently truncate on a partial write. The new writeAllSync throws instead. One semantic change worth noting: atomic-rename breaks hardlinks. The edited path now points at the new file while any other hardlink to the old inode is left pointing at the pre-edit content. Industry-standard tradeoff (same as the editor in your shell does), but a behavior change versus the old in-place truncate. New test covers the symlink-preservation case (edit through link → link still exists → referent file has new content). Thanks @YoungDan-hero for the careful patch.
This commit is contained in:
1 parent
7d49171a28
commit
b40a5692ec
2 files changed
+108
-16
No files matched your search
+79
-15
@@ -1,14 +1,17 @@
|
||||
/** SEARCH must match byte-for-byte; empty SEARCH = create new file. No fuzzy match — silent wrong edit beats a missing one. */
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
ftruncateSync,
|
||||
fsyncSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
writeSync,
|
||||
@@ -91,6 +94,61 @@ function pathIsUnder(child: string, parent: string): boolean {
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
}
|
||||
|
||||
function writeAllSync(fd: number, buf: Buffer): void {
|
||||
let written = 0;
|
||||
while (written < buf.length) {
|
||||
const n = writeSync(fd, buf, written, buf.length - written, written);
|
||||
if (n <= 0) throw new Error("write returned 0 bytes before completing");
|
||||
written += n;
|
||||
}
|
||||
}
|
||||
|
||||
function fsyncDirectoryBestEffort(path: string): void {
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(path, "r");
|
||||
fsyncSync(fd);
|
||||
} catch {
|
||||
/* directory fsync is best-effort across platforms */
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function atomicReplaceFileSync(path: string, buf: Buffer, mode: number): void {
|
||||
const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
||||
const permissions = mode & 0o7777;
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(tmp, "wx", permissions);
|
||||
writeAllSync(fd, buf);
|
||||
try {
|
||||
chmodSync(tmp, permissions);
|
||||
} catch {
|
||||
/* preserve mode when the platform allows it */
|
||||
}
|
||||
fsyncSync(fd);
|
||||
closeSync(fd);
|
||||
fd = undefined;
|
||||
renameSync(tmp, path);
|
||||
fsyncDirectoryBestEffort(dirname(path));
|
||||
} catch (err) {
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
/* fd may already be closed after a prior failure */
|
||||
}
|
||||
}
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch {
|
||||
/* tmp may not exist */
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
|
||||
const absRoot = resolve(rootDir);
|
||||
const absTarget = resolveEditPath(rootDir, block.path);
|
||||
@@ -135,9 +193,23 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
|
||||
try {
|
||||
// Modify path. ENOENT is reported as `file-missing` so the model
|
||||
// knows it needs an empty SEARCH to create the file.
|
||||
let fd: number;
|
||||
let writeTarget: string;
|
||||
try {
|
||||
fd = openSync(absTarget, "r+");
|
||||
writeTarget = realpathSync(absTarget);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
path: block.path,
|
||||
status: "file-missing",
|
||||
message: "file does not exist; to create it, use an empty SEARCH block",
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = openSync(writeTarget, "r+");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return {
|
||||
@@ -182,20 +254,12 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
|
||||
// a footgun when the same string legitimately appears in several
|
||||
// unrelated places.
|
||||
const replaced = `${content.slice(0, idx)}${adaptedReplace}${content.slice(idx + adaptedSearch.length)}`;
|
||||
// Truncate first so a shorter result doesn't leave stale tail
|
||||
// bytes; ftruncate also pads with NUL when the new length is
|
||||
// longer, which we then overwrite below.
|
||||
const outBuf = encodeFile(replaced, encoding);
|
||||
ftruncateSync(fd, outBuf.length);
|
||||
let written = 0;
|
||||
while (written < outBuf.length) {
|
||||
const n = writeSync(fd, outBuf, written, outBuf.length - written, written);
|
||||
if (n <= 0) break;
|
||||
written += n;
|
||||
}
|
||||
closeSync(fd);
|
||||
fd = undefined;
|
||||
atomicReplaceFileSync(writeTarget, encodeFile(replaced, encoding), stat.mode);
|
||||
return { path: block.path, status: "applied" };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
} catch (err) {
|
||||
return { path: block.path, status: "error", message: (err as Error).message };
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/** SEARCH/REPLACE parsing + application — fresh temp dir per test. */
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import iconv from "iconv-lite";
|
||||
@@ -222,6 +230,26 @@ describe("applyEditBlock", () => {
|
||||
expect(result.status).toBe("applied");
|
||||
expect(readFileSync(join(root, "lf.txt"), "utf8")).toBe("LINE1\nLINE2\n");
|
||||
});
|
||||
|
||||
it("preserves symlink targets instead of replacing the link with a regular file", () => {
|
||||
const outside = resolve(root, "..", "outside-symlink-target.txt");
|
||||
const link = join(root, "linked.txt");
|
||||
writeFileSync(outside, "hello world\n", "utf8");
|
||||
try {
|
||||
symlinkSync(outside, link);
|
||||
const result = applyEditBlock(
|
||||
{ path: "linked.txt", search: "hello", replace: "goodbye", offset: 0 },
|
||||
root,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("applied");
|
||||
expect(lstatSync(link).isSymbolicLink()).toBe(true);
|
||||
expect(readFileSync(outside, "utf8")).toBe("goodbye world\n");
|
||||
} finally {
|
||||
rmSync(link, { force: true });
|
||||
rmSync(outside, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapshotBeforeEdits + restoreSnapshots", () => {
|
||||
|
||||
Reference in new issue
Block a user