-
Notifications
You must be signed in to change notification settings - Fork 519
Presets: prevent re-entrant reapply loop triggered by symlinked preset files #4670
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Prevents an infinite presets reload loop (issue #4668) by adding re-entry protection to PresetsController.reapplyPresets(), and documents the fix.
Changes:
- Added an
_isReapplyingPresetsguard to prevent concurrent/recursivereapplyPresets()execution. - Added unit tests intended to validate re-entry protection and symlink behaviors.
- Updated changelog entry for the symlink-related infinite reload fix.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/presets/presetsController.ts | Adds a re-entry guard around reapplyPresets() to prevent recursive/concurrent reapply loops. |
| test/unit-tests/presets/presetsController.test.ts | Introduces tests around symlink handling and re-entry protection patterns. |
| CHANGELOG.md | Documents the bug fix for symlink-triggered infinite presets reload loops. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/presets/presetsController.ts
Outdated
| if (this._isReapplyingPresets) { | ||
| return; | ||
| } | ||
| this._isReapplyingPresets = true; | ||
|
|
||
| // reset all expanded presets storage. | ||
| this._referencedFiles = Array.from(referencedFiles.keys()); | ||
| try { | ||
| const referencedFiles: Map<string, preset.PresetsFile | undefined> = | ||
| new Map(); | ||
|
|
||
| // Reset all changes due to expansion since parents could change | ||
| await this._presetsParser.resetPresetsFiles( | ||
| referencedFiles, | ||
| this.project.workspaceContext.config.allowCommentsInPresetsFile, | ||
| this.project.workspaceContext.config.allowUnsupportedPresetsVersions | ||
| ); | ||
|
|
||
| this.project.minCMakeVersion = preset.minCMakeVersion(this.folderPath); | ||
| // reset all expanded presets storage. | ||
| this._referencedFiles = Array.from(referencedFiles.keys()); | ||
|
|
||
| if (this.project.configurePreset) { | ||
| await this.setConfigurePreset(this.project.configurePreset.name); | ||
| } | ||
| // Don't need to set build/test presets here since they are reapplied in setConfigurePreset | ||
| this.project.minCMakeVersion = preset.minCMakeVersion(this.folderPath); | ||
|
|
||
| if (this.project.configurePreset) { | ||
| await this.setConfigurePreset(this.project.configurePreset.name); | ||
| } | ||
| // Don't need to set build/test presets here since they are reapplied in setConfigurePreset | ||
|
|
||
| await this.watchPresetsChange(); | ||
| await this.watchPresetsChange(); | ||
| } finally { | ||
| this._isReapplyingPresets = false; | ||
| } |
Copilot
AI
Jan 29, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
reapplyPresets() now returns early when a reapply is already in progress. This can drop legitimate file-watcher/config change events that happen during a long-running reapply, leaving presets state stale (the FileWatcher also debounces events for 500ms, increasing the chance of missing the follow-up event). Consider tracking a pending reapply (e.g., set a _reapplyPresetsPending flag when re-entry is detected) and running one more reapply in finally after clearing _isReapplyingPresets.
| /* eslint-disable no-unused-expressions */ | ||
| import { expect } from 'chai'; | ||
| import * as path from 'path'; | ||
| import * as fs from 'fs'; | ||
| import * as os from 'os'; |
Copilot
AI
Jan 29, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test imports expect directly from chai, but most preset-related tests use the shared @test/util export (which also configures chai plugins like chai-as-promised). To keep assertions/config consistent across the test suite, import expect from @test/util here as well.
| // Remove symlink first if it exists | ||
| if (symlinkPath && fs.existsSync(symlinkPath)) { | ||
| try { | ||
| // On Windows, junctions need special handling | ||
| const stats = fs.lstatSync(symlinkPath); | ||
| if (stats.isSymbolicLink() || stats.isDirectory()) { | ||
| if (process.platform === 'win32') { | ||
| // Use rmdir for junctions on Windows | ||
| fs.rmdirSync(symlinkPath); | ||
| } else { | ||
| fs.unlinkSync(symlinkPath); | ||
| } | ||
| } | ||
| } catch { | ||
| // Ignore cleanup errors | ||
| } | ||
| } | ||
| // Remove the presets directory and its contents | ||
| if (fs.existsSync(presetsDir)) { | ||
| if (fs.existsSync(presetsJsonPath)) { | ||
| fs.unlinkSync(presetsJsonPath); | ||
| } | ||
| fs.rmdirSync(presetsDir); | ||
| } | ||
| // Remove the temp directory | ||
| try { | ||
| fs.rmdirSync(tempDir); | ||
| } catch { | ||
| // Ignore if not empty or already deleted |
Copilot
AI
Jan 29, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test cleanup uses fs.rmdirSync/manual unlinking, which is error-prone and can make tests flaky if a test fails before removing all created files (e.g., the include file). Other tests in this repo use fs.rmSync(..., { recursive: true, force: true }); consider switching to that pattern to reliably clean up tempDir and avoid teardown failures.
| // Remove symlink first if it exists | |
| if (symlinkPath && fs.existsSync(symlinkPath)) { | |
| try { | |
| // On Windows, junctions need special handling | |
| const stats = fs.lstatSync(symlinkPath); | |
| if (stats.isSymbolicLink() || stats.isDirectory()) { | |
| if (process.platform === 'win32') { | |
| // Use rmdir for junctions on Windows | |
| fs.rmdirSync(symlinkPath); | |
| } else { | |
| fs.unlinkSync(symlinkPath); | |
| } | |
| } | |
| } catch { | |
| // Ignore cleanup errors | |
| } | |
| } | |
| // Remove the presets directory and its contents | |
| if (fs.existsSync(presetsDir)) { | |
| if (fs.existsSync(presetsJsonPath)) { | |
| fs.unlinkSync(presetsJsonPath); | |
| } | |
| fs.rmdirSync(presetsDir); | |
| } | |
| // Remove the temp directory | |
| try { | |
| fs.rmdirSync(tempDir); | |
| } catch { | |
| // Ignore if not empty or already deleted | |
| try { | |
| fs.rmSync(tempDir, { recursive: true, force: true }); | |
| } catch { | |
| // Ignore cleanup errors |
| /** | ||
| * Test the re-entry prevention logic pattern used in PresetsController. | ||
| * This is a simplified simulation of the fix for issue #4668. | ||
| */ | ||
| test('Re-entry prevention flag blocks concurrent calls', async () => { | ||
| // Simulate the _isReapplyingPresets flag pattern | ||
| let isReapplyingPresets = false; | ||
| let reapplyCallCount = 0; | ||
| const callOrder: string[] = []; | ||
|
|
||
| // Simulate the reapplyPresets function with the fix | ||
| const reapplyPresets = async (): Promise<void> => { | ||
| if (isReapplyingPresets) { | ||
| callOrder.push('blocked'); | ||
| return; | ||
| } | ||
| isReapplyingPresets = true; | ||
| reapplyCallCount++; | ||
| callOrder.push('started'); | ||
|
|
||
| try { | ||
| // Simulate async work that might trigger file watcher events | ||
| await new Promise(resolve => setTimeout(resolve, 10)); | ||
|
|
||
| // Simulate a recursive call (as would happen from file watcher) | ||
| await reapplyPresets(); | ||
|
|
||
| callOrder.push('completed'); | ||
| } finally { | ||
| isReapplyingPresets = false; | ||
| } | ||
| }; | ||
|
|
||
| // Call reapplyPresets | ||
| await reapplyPresets(); | ||
|
|
||
| // Verify only one actual execution happened | ||
| expect(reapplyCallCount).to.equal(1); | ||
|
|
||
| // Verify the call order shows the recursive call was blocked | ||
| expect(callOrder).to.deep.equal(['started', 'blocked', 'completed']); | ||
| }); |
Copilot
AI
Jan 29, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tests titled around “re-entry prevention” only simulate a local boolean and never exercise PresetsController.reapplyPresets() itself. That means the new _isReapplyingPresets behavior (and the symlink-triggered loop regression) isn’t actually covered. Consider constructing a PresetsController with a stubbed/mocked PresetsParser and calling reapplyPresets() twice (including a re-entrant call path) to assert the guard works in the real implementation.
| test('Symlinked preset files can be read correctly', function () { | ||
| if (!symlinkSupported) { | ||
| this.skip(); | ||
| return; | ||
| } | ||
|
|
||
| // Create an include file in the presets directory | ||
| const includeFilePath = path.join(presetsDir, 'include-presets.json'); | ||
| const includeContent = JSON.stringify({ | ||
| version: 3, | ||
| configurePresets: [ | ||
| { | ||
| name: "included-preset", | ||
| displayName: "Included Preset", | ||
| generator: "Ninja" | ||
| } | ||
| ] | ||
| }, null, 2); | ||
| fs.writeFileSync(includeFilePath, includeContent); | ||
|
|
||
| // Update the main presets file to include the other file | ||
| const mainPresets = { | ||
| version: 3, | ||
| include: ['include-presets.json'], | ||
| configurePresets: [ | ||
| { | ||
| name: "main-preset", | ||
| displayName: "Main Preset", | ||
| generator: "Ninja" | ||
| } | ||
| ] | ||
| }; | ||
| fs.writeFileSync(presetsJsonPath, JSON.stringify(mainPresets, null, 2)); | ||
|
|
||
| // Get the path through the symlink/junction | ||
| const includeFileViaSym = process.platform === 'win32' | ||
| ? path.join(symlinkPath, 'include-presets.json') | ||
| : presetsJsonPath; // On Unix, symlinkPath points directly to the file | ||
|
|
Copilot
AI
Jan 29, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In Symlinked preset files can be read correctly, the Unix path (includeFileViaSym) is set to presetsJsonPath (the main file), so the test doesn't actually read the include file through any symlink on Unix and effectively only asserts anything on Windows. Either adjust the setup so the include file is accessed via a symlinked directory on Unix too, or rename/scope the test so it only claims to validate what it actually checks.
This change addresses item #4668
This pull request addresses a bug related to infinite reloading loops when using symlinked preset files in the presets system. The main change is the introduction of a guard to prevent the
reapplyPresetsmethod from being called recursively or concurrently, which could previously lead to an infinite loop.This changes visible behavior and fixes bug
The following changes are proposed:
_isReapplyingPresets) in thePresetsControllerclass to prevent multiple simultaneous executions of thereapplyPresetsmethod, fixing infinite reload loops when preset files are symlinks or include symlinked files. [1] [2] [3]