Skip to content

Commit 8a495d0

Browse files
committed
fix(liangshen): copy preset trees without fs.cpSync to survive CJK paths on Windows
fs.cpSync({ recursive: true }) on Node 22 for Windows aborts the whole process (STATUS_STACK_BUFFER_OVERRUN / 0xC0000409, no JS exception) when the source path contains non-ASCII characters such as a CJK home directory (nodejs/node#54476, regression from nodejs/node#53614). The plugin declares engines "^22.19.0 || >=24.0.0", so Node 22 must work. Replace the two cpSync call sites with a per-entry recursive copy (mkdirSync + copyFileSync + utimesSync to preserve mtimes) and add a regression test using a CJK preset directory name.
1 parent b6a5a3d commit 8a495d0

2 files changed

Lines changed: 49 additions & 5 deletions

File tree

‎packages/dsh-liangshen/src/sync.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ describe('syncPresetTrees', () => {
5252
} finally { f.dispose() }
5353
})
5454

55+
it('copies a preset whose source path contains non-ASCII characters', () => {
56+
const f = fixture()
57+
try {
58+
// Regression test for nodejs/node#54476: on Node 22 for Windows,
59+
// fs.cpSync({ recursive: true }) aborts the process (0xC0000409) when
60+
// the source path contains CJK characters, e.g. a CJK home directory.
61+
// sync.ts must not use fs.cpSync; a CJK user profile must still boot.
62+
const chinese = join(f.source, '梁神')
63+
mkdirSync(chinese, { recursive: true })
64+
writeFileSync(join(chinese, 'agent.cordis.yml'), VALID_AGENT_YAML)
65+
writeFileSync(join(chinese, 'preset.yml'), 'name: 梁神模式\n')
66+
const result = syncPresetTrees(f.source, f.target)
67+
expect(result.synced).toContain('梁神')
68+
expect(readFileSync(join(f.target, '梁神', 'agent.cordis.yml'), 'utf8')).toBe(VALID_AGENT_YAML)
69+
} finally { f.dispose() }
70+
})
71+
5572
it('retires a previously bundled preset directory removed from the source', () => {
5673
const f = fixture()
5774
try {

‎packages/dsh-liangshen/src/sync.ts‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* can observe (and surface) a broken preset rather than silently shipping it.
1616
*/
1717

18-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
18+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, utimesSync } from 'node:fs'
1919
import { basename, dirname, join, relative } from 'node:path'
2020
import { validateAgentCordis } from './schema.ts'
2121

@@ -99,6 +99,33 @@ function validatePresetAgentFile(presetDir: string): string[] {
9999
return validateAgentCordis(readFileSync(agent, 'utf8'))
100100
}
101101

102+
/**
103+
* Copy the whole tree under `sourceDir` into `targetDir`, creating the target
104+
* directory as needed. Intentionally not `fs.cpSync` (recursive): on Node 22
105+
* for Windows, `fs.cpSync` with `recursive: true` crashes the process with a
106+
* fatal error (STATUS_STACK_BUFFER_OVERRUN / 0xC0000409, no JS exception is
107+
* thrown) whenever the source path contains non-ASCII characters such as a
108+
* CJK home directory (nodejs/node#54476, regression from nodejs/node#53614).
109+
* Since `engines` supports Node ^22.19.0, this must work on Node 22, so the
110+
* copy is done with the same per-entry primitives the rest of the module
111+
* already uses. Source mtimes are preserved to keep the `preserveTimestamps`
112+
* contract of the previous `cpSync` call.
113+
*/
114+
function copyTreeSync(sourceDir: string, targetDir: string): void {
115+
mkdirSync(targetDir, { recursive: true })
116+
for (const entry of readdirSync(sourceDir)) {
117+
const source = join(sourceDir, entry)
118+
const target = join(targetDir, entry)
119+
const stat = statSync(source)
120+
if (stat.isDirectory()) {
121+
copyTreeSync(source, target)
122+
} else {
123+
copyFileSync(source, target)
124+
utimesSync(target, stat.atime, stat.mtime)
125+
}
126+
}
127+
}
128+
102129
/** Copy `sourceRoot/<id>` into `targetRoot/<id>`, idempotently. */
103130
export function syncOnePreset(sourceDir: string, targetDir: string): 'synced' | 'current' {
104131
const sourceFiles = filesUnder(sourceDir)
@@ -108,7 +135,7 @@ export function syncOnePreset(sourceDir: string, targetDir: string): 'synced' |
108135
rmSync(targetDir, { recursive: true, force: true })
109136
}
110137
if (!existsSync(targetDir)) {
111-
cpSync(sourceDir, targetDir, { recursive: true, preserveTimestamps: true })
138+
copyTreeSync(sourceDir, targetDir)
112139
pruneExtras(targetDir, sourceSet)
113140
return 'synced'
114141
}
@@ -131,10 +158,10 @@ export function syncOnePreset(sourceDir: string, targetDir: string): 'synced' |
131158
}
132159
if (!dirty) return 'current'
133160

134-
// Drop target-only entries first so file/dir type clashes never reach cpSync,
135-
// then copy and prune again per the post-copy contract.
161+
// Drop target-only entries first so file/dir type clashes never reach the
162+
// copy, then copy and prune again per the post-copy contract.
136163
pruneExtras(targetDir, sourceSet)
137-
cpSync(sourceDir, targetDir, { recursive: true, preserveTimestamps: true })
164+
copyTreeSync(sourceDir, targetDir)
138165
pruneExtras(targetDir, sourceSet)
139166
return 'synced'
140167
}

0 commit comments

Comments
 (0)