Skip dangling symlinks when archiving site content#4038
Conversation
Reprint-pulled WP Cloud sites keep the advanced-cache.php drop-in as a symlink whose target (wordpress/drop-ins/...) isn't pulled, so it dangles. fs.realpathSync on it threw ENOENT and aborted the whole archive, failing 'preview create'/'update' (and push export) with 'Failed to create preview site: ENOENT ... advanced-cache.php'. Skip entries whose realpath fails, in both archiveSiteContent and the default exporter.
📊 Performance Test ResultsComparing 4ab9f7a vs trunk app-size
site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff) |
fredrikekelund
left a comment
There was a problem hiding this comment.
Good fix regardless of whether the site is Reprint-pulled or not 👍
One thing worth looking into is the calculateDirectorySizeForArchive() function. I'm getting a warning like this from there:
Error processing /Users/fredrik/Studio/my-true-website/wp-content/advanced-cache.php: Error: ENOENT: no such file or directory, stat '/Users/fredrik/Studio/my-true-website/wp-content/advanced-cache.php'
at async Object.stat (node:internal/fs/promises:1038:18)
at async file:///Users/fredrik/Code/studio/apps/cli/dist/cli/rewrite-wp-cli-post-content-wf2YZvuH.mjs:44:22
at async Promise.all (index 0)
at async calculateSize (file:///Users/fredrik/Code/studio/apps/cli/dist/cli/rewrite-wp-cli-post-content-wf2YZvuH.mjs:36:5) {
errno: -2,
code: 'ENOENT',
syscall: 'stat',
path: '/Users/fredrik/Studio/my-true-website/wp-content/advanced-cache.php'
This looks like the exact same underlying problem, so it seems sensible to address in this PR
| let resolvedPath: string; | ||
| try { | ||
| resolvedPath = fs.realpathSync( path.join( wpContentPath, relativePath ) ); | ||
| } catch ( error ) { | ||
| // Dangling symlink. Skip it rather than aborting the whole archive. | ||
| console.warn( `Skipping ${ archiveEntryPath }: ${ error }` ); | ||
| continue; | ||
| } | ||
| archiveBuilder.file( resolvedPath, { | ||
| name: archiveEntryPath, | ||
| } ); |
There was a problem hiding this comment.
| let resolvedPath: string; | |
| try { | |
| resolvedPath = fs.realpathSync( path.join( wpContentPath, relativePath ) ); | |
| } catch ( error ) { | |
| // Dangling symlink. Skip it rather than aborting the whole archive. | |
| console.warn( `Skipping ${ archiveEntryPath }: ${ error }` ); | |
| continue; | |
| } | |
| archiveBuilder.file( resolvedPath, { | |
| name: archiveEntryPath, | |
| } ); | |
| try { | |
| const resolvedPath = fs.realpathSync( path.join( wpContentPath, relativePath ) ); | |
| archiveBuilder.file( resolvedPath, { name: archiveEntryPath } ); | |
| } catch ( error ) { | |
| // Dangling symlink. Skip it rather than aborting the whole archive. | |
| console.warn( `Skipping ${ archiveEntryPath }: ${ error }` ); | |
| } |
Optional nit, but it doesn't seem necessary to call fs.realpathSync() in a different scope to archiveBuilder.file()
| let resolvedPath: string; | ||
| try { | ||
| resolvedPath = fs.realpathSync( fullEntryPathOnDisk ); | ||
| } catch ( error ) { | ||
| // Dangling symlink. Skip it rather than aborting the whole archive. | ||
| console.warn( `Skipping ${ entryPathRelativeToArchiveRoot }: ${ error }` ); | ||
| continue; | ||
| } | ||
| this.archiveBuilder.file( resolvedPath, { | ||
| name: entryPathRelativeToArchiveRoot, | ||
| } ); |
There was a problem hiding this comment.
| let resolvedPath: string; | |
| try { | |
| resolvedPath = fs.realpathSync( fullEntryPathOnDisk ); | |
| } catch ( error ) { | |
| // Dangling symlink. Skip it rather than aborting the whole archive. | |
| console.warn( `Skipping ${ entryPathRelativeToArchiveRoot }: ${ error }` ); | |
| continue; | |
| } | |
| this.archiveBuilder.file( resolvedPath, { | |
| name: entryPathRelativeToArchiveRoot, | |
| } ); | |
| try { | |
| const resolvedPath = fs.realpathSync( path.join( wpContentPath, relativePath ) ); | |
| archiveBuilder.file( resolvedPath, { name: archiveEntryPath } ); | |
| } catch ( error ) { | |
| // Dangling symlink. Skip it rather than aborting the whole archive. | |
| console.warn( `Skipping ${ archiveEntryPath }: ${ error }` ); | |
| } |
Optional nit. Same thing here
I agree, the user could create dangling symlinks and we don't want to break some flows.
Yes, that's the same error, let me look at it |
The push file-selection UI computes sizes via the getFileSize IPC handler and calculateDirectorySizeForArchive, both of which stat through symlinks. A dangling symlink (e.g. the advanced-cache.php drop-in on reprint-pulled sites) made getFileSize throw — logged twice with full stacks by Electron — and made the directory walk log a noisy stack trace. Since such entries are skipped when archiving, count them as zero and log a single Skipping line instead.
|
Thanks for the review @fredrikekelund, I have addressed the other functions that were causing noisy warnings. I have turned down the warning so it's still logged that the symlink is skipped, but in a controlled way. Please let me know your view on that, the commit is 4ab9f7a |
Related issues
advanced-cache.phpdrop-in symlink.How AI was used in this PR
Claude Code reproduced the failure, traced it to
fs.realpathSyncon a dangling symlink aborting the archive, wrote the guard and the unit test, and verified the fix end-to-end (a real preview-site creation against an affected site). I reviewed the diff.Proposed Changes
Creating a preview site — and pushing — from a reprint-pulled WP Cloud site failed with:
Failed to create preview site: ENOENT: no such file or directory, stat '.../wp-content/advanced-cache.php'Those sites keep the WP Cloud
advanced-cache.phpcache drop-in as a symlink pointing intowordpress/drop-ins/, which the pull doesn't download — so it dangles. When archivingwp-content,fs.realpathSyncon that broken link threwENOENTand aborted the whole archive.Now a dangling symlink is skipped (with a warning) instead of failing the whole operation, so preview create/update and push succeed. This makes the archiver robust to any broken symlink — which matters for sites already pulled, since they keep the dangling drop-in until re-pulled.
The reprint side — not creating the dangling symlink in the first place — is fixed separately in
WordPress/reprint.Testing Instructions
wp-content/advanced-cache.phpis a broken symlink), rebuild the CLI (npm run cli:build) and run:node apps/cli/dist/cli/main.mjs preview create --path ~/Studio/<site>ENOENT ... advanced-cache.php. After: the preview site is created successfully.npm test -- apps/cli/lib/tests/archive.test.ts(includes a dangling-symlink case).Pre-merge Checklist
tsc -p apps/cliclean, ESLint clean)