Skip to content

feat(core,cli): add generic atomicWriteFile, wire into Write/Edit tools, upgrade @types/node#4096

Merged
doudouOUC merged 9 commits into
mainfrom
worktree-linked-drifting-alpaca
May 15, 2026
Merged

feat(core,cli): add generic atomicWriteFile, wire into Write/Edit tools, upgrade @types/node#4096
doudouOUC merged 9 commits into
mainfrom
worktree-linked-drifting-alpaca

Conversation

@doudouOUC

@doudouOUC doudouOUC commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add atomicWriteFile() to packages/core/src/utils/atomicFileWrite.ts — a generic atomic write function supporting string/Buffer with flush (fsync), permission preservation, symlink chain resolution, EXDEV cross-device fallback, and FAT/exFAT chmod tolerance
  • Wire StandardFileSystemService.writeTextFile() through atomicWriteFile, making all Write and Edit tool file operations atomic
  • Refactor atomicWriteJSON to delegate to atomicWriteFile (adds previously missing fsync)
  • Deduplicate renameWithRetry from runtimeStatus.ts (was copy-pasted from atomicFileWrite.ts)
  • Add flush: true to writeWithBackupSync for settings writes
  • Upgrade @types/node to ^22.0.0 across all packages (matches engine requirement, enables flush option types)
  • Update edit.test.ts FILE_WRITE_FAILURE test: use mock instead of chmod 444 (atomic write bypasses readonly file check since tmp file creation succeeds)

This directly closes the TODOs in write-file.ts:371-385 and edit.ts:487-497:

the only way to close it is an atomic write (write-to-temp + rename)... deferred to a follow-up

Ref: #4095 (Phase 1)

Test plan

  • npx tsc --noEmit -p packages/core — typecheck clean
  • atomicFileWrite.test.ts — 18 tests pass (5 existing + 13 new)
  • runtimeStatus.test.ts — 19 tests pass (no regression from dedup)
  • fileSystemService.test.ts — 41 tests pass (mock updated for atomicWriteFile)
  • edit.test.ts — 75 tests pass (FILE_WRITE_FAILURE test updated for atomic write)
  • write-file.test.ts — 45 tests pass

Manual E2E verification

Save the following script as atomic-e2e-test.ts and run with npx tsx atomic-e2e-test.ts from the repo root:

atomic-e2e-test.ts (click to expand)
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
import { atomicWriteFile } from './packages/core/src/utils/atomicFileWrite.js';

async function main() {
  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'atomic-e2e-'));
  console.log('Test dir:', tmpDir);
  let allPass = true;

  // Test 1: Write new file, no tmp residue
  const f1 = path.join(tmpDir, 'test1.txt');
  await atomicWriteFile(f1, 'hello atomic');
  const content1 = await fs.readFile(f1, 'utf-8');
  const files1 = await fs.readdir(tmpDir);
  const t1 = content1 === 'hello atomic' && files1.length === 1;
  console.log(t1 ? '✓' : '✗', 'Test 1 - Write new file, no tmp residue');
  if (!t1) allPass = false;

  // Test 2: Permission preservation
  const f2 = path.join(tmpDir, 'test2.txt');
  await fs.writeFile(f2, 'old content');
  await fs.chmod(f2, 0o600);
  await atomicWriteFile(f2, 'new content');
  const stat2 = await fs.stat(f2);
  const content2 = await fs.readFile(f2, 'utf-8');
  const t2 = content2 === 'new content' && (stat2.mode & 0o777) === 0o600;
  console.log(t2 ? '✓' : '✗', 'Test 2 - Permission preservation (mode:', (stat2.mode & 0o777).toString(8) + ')');
  if (!t2) allPass = false;

  // Test 3: Symlink preservation
  const realFile = path.join(tmpDir, 'real.txt');
  const linkFile = path.join(tmpDir, 'link.txt');
  await fs.writeFile(realFile, 'real data');
  await fs.symlink(realFile, linkFile);
  await atomicWriteFile(linkFile, 'updated data');
  const linkTarget = await fs.readlink(linkFile);
  const realContent = await fs.readFile(realFile, 'utf-8');
  const t3 = linkTarget === realFile && realContent === 'updated data';
  console.log(t3 ? '✓' : '✗', 'Test 3 - Symlink preserved, real file updated');
  if (!t3) allPass = false;

  // Test 4: Broken symlink write-through
  const brokenTarget = path.join(tmpDir, 'not-yet.txt');
  const brokenLink = path.join(tmpDir, 'broken.txt');
  await fs.symlink(brokenTarget, brokenLink);
  await atomicWriteFile(brokenLink, 'created via broken link');
  const brokenLinkTarget = await fs.readlink(brokenLink);
  const createdContent = await fs.readFile(brokenTarget, 'utf-8');
  const t4 = brokenLinkTarget === brokenTarget && createdContent === 'created via broken link';
  console.log(t4 ? '✓' : '✗', 'Test 4 - Broken symlink write-through');
  if (!t4) allPass = false;

  // Test 5: Multi-level symlink chain
  const chainReal = path.join(tmpDir, 'chain-real.txt');
  const chainA = path.join(tmpDir, 'chain-a.txt');
  const chainB = path.join(tmpDir, 'chain-b.txt');
  await fs.writeFile(chainReal, 'original');
  await fs.symlink(chainReal, chainA);
  await fs.symlink(chainA, chainB);
  await atomicWriteFile(chainB, 'chain updated');
  const chainContent = await fs.readFile(chainReal, 'utf-8');
  const chainATarget = await fs.readlink(chainA);
  const chainBTarget = await fs.readlink(chainB);
  const t5 = chainContent === 'chain updated' && chainATarget === chainReal && chainBTarget === chainA;
  console.log(t5 ? '✓' : '✗', 'Test 5 - Multi-level symlink chain');
  if (!t5) allPass = false;

  await fs.rm(tmpDir, { recursive: true, force: true });
  console.log('\n' + (allPass ? 'ALL PASS' : 'SOME FAILED'));
  process.exit(allPass ? 0 : 1);
}

main();

Expected output:

Test dir: /tmp/atomic-e2e-XXXXXX
✓ Test 1 - Write new file, no tmp residue
✓ Test 2 - Permission preservation (mode: 600)
✓ Test 3 - Symlink preserved, real file updated
✓ Test 4 - Broken symlink write-through
✓ Test 5 - Multi-level symlink chain

ALL PASS
# Test Input Expected
1 Write new file atomicWriteFile(f1, 'hello atomic') File contains hello atomic; no .tmp files in directory
2 Permission preservation Create file with chmod 600, then atomicWriteFile(f2, 'new content') Content updated to new content; stat.mode & 0o777 == 0o600
3 Symlink preservation Create real.txt → symlink link.txt, write via link.txt readlink(link.txt) still points to real.txt; real.txt contains updated data
4 Broken symlink write-through Symlink to non-existent target, write via symlink Symlink unchanged; target file created with written content
5 Multi-level symlink chain B → A → real, write via B Both links preserved; real.txt updated to chain updated

Note: Test 2 (permission) will be skipped on Windows — chmod is a no-op there.

🤖 Generated with Qwen Code


📝 描述准确性更新(2026-05-31,作者自查)

补充:atomicWriteFile 经 temp+rename 换 inode → 不保留 uid/gid(后由 #4431 处理)、且可改写只读(444)文件(行为变更);EXDEV 回退因 temp 与目标同目录而实为死代码。

Copilot AI review requested due to automatic review settings May 12, 2026 18:05
@doudouOUC doudouOUC self-assigned this May 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a generic atomic file-write utility in core and wires core file-writing paths (notably StandardFileSystemService.writeTextFile, which underpins Write/Edit tools) to use atomic write semantics (temp file + rename), with additional durability via fsync and permission preservation. It also refactors existing atomic JSON writing to reuse the new helper, deduplicates rename-retry logic, and aligns the repo’s Node type definitions with the Node >=22 engine requirement.

Changes:

  • Add atomicWriteFile() (string/Buffer, fsync, permission handling, symlink-aware targeting, EXDEV fallback) and refactor atomicWriteJSON() to delegate to it.
  • Route StandardFileSystemService.writeTextFile() through atomicWriteFile() to make Write/Edit operations atomic.
  • Add flush: true to synchronous settings writes and upgrade @types/node to ^22.0.0 across packages.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/core/src/utils/atomicFileWrite.ts Adds generic atomic write helper; refactors atomic JSON writes; exports renameWithRetry.
packages/core/src/utils/atomicFileWrite.test.ts Adds tests for atomicWriteFile behavior (string/buffer, perms, temp cleanup, symlink case).
packages/core/src/services/fileSystemService.ts Switches text writes to atomicWriteFile for atomic Write/Edit tool behavior.
packages/core/src/services/fileSystemService.test.ts Mocks atomicWriteFile to keep file system service tests working.
packages/core/src/utils/runtimeStatus.ts Uses atomicWriteJSON for runtime status writes and removes local rename retry implementation.
packages/cli/src/utils/writeWithBackup.ts Adds flush: true to temp writes for better durability.
packages/cli/package.json Upgrades @types/node to ^22.0.0.
packages/sdk-typescript/package.json Upgrades @types/node to ^22.0.0.
packages/vscode-ide-companion/package.json Upgrades @types/node to ^22.0.0.
packages/vscode-ide-companion/NOTICES.txt Updates third-party notice entry for scheduler version.
package-lock.json Lockfile updates reflecting dependency/type upgrades.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/src/utils/atomicFileWrite.ts Outdated
Comment thread packages/core/src/utils/atomicFileWrite.ts Outdated
Comment thread packages/core/src/utils/atomicFileWrite.test.ts
@github-actions

github-actions Bot commented May 12, 2026

Copy link