-
Notifications
You must be signed in to change notification settings - Fork 57
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
feat: enable framework detection on js workspace root #5853
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import { describe, beforeEach, test, expect } from 'vitest' | ||
|
||
import { Mutex } from './mutex.js' | ||
|
||
describe('Mutex', () => { | ||
let mutex: Mutex | ||
|
||
beforeEach(() => { | ||
mutex = new Mutex() | ||
}) | ||
|
||
test('should acquire and release the lock for a single operation', async () => { | ||
let isLocked = false | ||
|
||
await mutex.runExclusive(async () => { | ||
isLocked = mutex['_isLocked'] | ||
expect(isLocked).toBe(true) | ||
}) | ||
|
||
isLocked = mutex['_isLocked'] | ||
expect(isLocked).toBe(false) | ||
}) | ||
|
||
test('should run operations sequentially', async () => { | ||
const results: number[] = [] | ||
|
||
const task = (id: number) => { | ||
return mutex.runExclusive(async () => { | ||
results.push(id) | ||
// Simulate async operation | ||
await new Promise((resolve) => setTimeout(resolve, 10)) | ||
}) | ||
} | ||
|
||
await Promise.all([task(1), task(2), task(3)]) | ||
|
||
expect(results).toEqual([1, 2, 3]) | ||
}) | ||
|
||
test('should prevent concurrent access to critical section', async () => { | ||
let concurrentAccesses = 0 | ||
let maxConcurrentAccesses = 0 | ||
|
||
const task = () => { | ||
return mutex.runExclusive(async () => { | ||
concurrentAccesses++ | ||
if (concurrentAccesses > maxConcurrentAccesses) { | ||
maxConcurrentAccesses = concurrentAccesses | ||
} | ||
// Simulate async operation | ||
await new Promise((resolve) => setTimeout(resolve, 10)) | ||
concurrentAccesses-- | ||
}) | ||
} | ||
|
||
await Promise.all([task(), task(), task()]) | ||
|
||
expect(maxConcurrentAccesses).toBe(1) | ||
}) | ||
|
||
test('should release the lock even if an exception occurs', async () => { | ||
let isLockedDuringError = false | ||
|
||
const failingTask = () => { | ||
return mutex.runExclusive(async () => { | ||
isLockedDuringError = mutex['_isLocked'] | ||
throw new Error('Intentional Error') | ||
}) | ||
} | ||
|
||
await expect(failingTask()).rejects.toThrow('Intentional Error') | ||
|
||
const isLockedAfterError = mutex['_isLocked'] | ||
expect(isLockedDuringError).toBe(true) | ||
expect(isLockedAfterError).toBe(false) | ||
}) | ||
|
||
test('should handle mixed successful and failing operations', async () => { | ||
const results: string[] = [] | ||
|
||
const successfulTask = () => { | ||
return mutex.runExclusive(async () => { | ||
results.push('success') | ||
await new Promise((resolve) => setTimeout(resolve, 10)) | ||
}) | ||
} | ||
|
||
const failingTask = () => { | ||
return mutex.runExclusive(async () => { | ||
results.push('fail') | ||
await new Promise((resolve) => setTimeout(resolve, 10)) | ||
throw new Error('Intentional Error') | ||
}) | ||
} | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-empty-function | ||
const tasks = [successfulTask(), failingTask().catch(() => {}), successfulTask()] | ||
|
||
await Promise.all(tasks) | ||
|
||
expect(results).toEqual(['success', 'fail', 'success']) | ||
expect(mutex['_isLocked']).toBe(false) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
/** | ||
* A lock for synchronizing async operations. | ||
* Use this to protect a critical section | ||
* from getting modified by multiple async operations | ||
* at the same time. | ||
*/ | ||
export class Mutex { | ||
/** | ||
* When multiple operations attempt to acquire the lock, | ||
* this queue remembers the order of operations. | ||
*/ | ||
private _queue: { | ||
resolve: (release: ReleaseFunction) => void | ||
}[] = [] | ||
|
||
private _isLocked = false | ||
|
||
/** | ||
* Wait until the lock is acquired. | ||
* @returns A function that releases the acquired lock. | ||
*/ | ||
acquire() { | ||
return new Promise<ReleaseFunction>((resolve) => { | ||
this._queue.push({ resolve }) | ||
this._dispatch() | ||
}) | ||
} | ||
|
||
/** | ||
* Enqueue a function to be run serially. | ||
* | ||
* This ensures no other functions will start running | ||
* until `callback` finishes running. | ||
* @param callback Function to be run exclusively. | ||
* @returns The return value of `callback`. | ||
*/ | ||
async runExclusive<T>(callback: () => Promise<T>) { | ||
const release = await this.acquire() | ||
try { | ||
return await callback() | ||
} finally { | ||
release() | ||
} | ||
} | ||
|
||
/** | ||
* Check the availability of the resource | ||
* and provide access to the next operation in the queue. | ||
* | ||
* _dispatch is called whenever availability changes, | ||
* such as after lock acquire request or lock release. | ||
*/ | ||
private _dispatch() { | ||
if (this._isLocked) { | ||
// The resource is still locked. | ||
// Wait until next time. | ||
return | ||
} | ||
const nextEntry = this._queue.shift() | ||
if (!nextEntry) { | ||
// There is nothing in the queue. | ||
// Do nothing until next dispatch. | ||
return | ||
} | ||
// The resource is available. | ||
this._isLocked = true // Lock it. | ||
// and give access to the next operation | ||
// in the queue. | ||
nextEntry.resolve(this._buildRelease()) | ||
} | ||
|
||
/** | ||
* Build a release function for each operation | ||
* so that it can release the lock after | ||
* the operation is complete. | ||
*/ | ||
private _buildRelease(): ReleaseFunction { | ||
return () => { | ||
// Each release function make | ||
// the resource available again | ||
this._isLocked = false | ||
// and call dispatch. | ||
this._dispatch() | ||
} | ||
} | ||
} | ||
|
||
type ReleaseFunction = () => void |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
import type { Client, NotifiableError } from '@bugsnag/js' | ||
import { isEqual } from 'lodash-es' | ||
import type { PackageJson } from 'read-pkg' | ||
import { SemVer, coerce, parse } from 'semver' | ||
|
||
|
@@ -11,6 +12,7 @@ import { frameworks } from './frameworks/index.js' | |
import { getFramework } from './get-framework.js' | ||
import { Logger } from './logger.js' | ||
import { Severity, report } from './metrics.js' | ||
import { Mutex } from './mutex.js' | ||
import { | ||
AVAILABLE_PACKAGE_MANAGERS, | ||
PkgManagerFields, | ||
|
@@ -295,8 +297,17 @@ export class Project { | |
// This needs to be run first | ||
await this.detectBuildSystem() | ||
this.frameworks = new Map() | ||
// A monorepo can have something on the repository root as well. | ||
const rootFrameworks = await this.detectFrameworksInPath() | ||
// Nx build system installs all dependencies on the root which would lead to double detection for root projects | ||
const isNx = this.buildSystems.some(({ id }) => id === 'nx') | ||
|
||
const rootFrameworkMutex = new Mutex() | ||
if (this.workspace) { | ||
if (rootFrameworks.length > 0 && !isNx) { | ||
this.workspace.packages.push({ path: '', name: 'root' }) | ||
this.frameworks.set('', rootFrameworks) | ||
} | ||
// if we have a workspace parallelize in all workspaces | ||
await Promise.all( | ||
this.workspace.packages.map(async ({ path: pkg, forcedFramework }) => { | ||
|
@@ -308,15 +319,24 @@ export class Project { | |
// noop framework not found | ||
} | ||
} else if (this.workspace) { | ||
const result = await this.detectFrameworksInPath(this.fs.join(this.workspace.rootDir, pkg)) | ||
this.frameworks.set(pkg, result) | ||
const results = await this.detectFrameworksInPath(this.fs.join(this.workspace.rootDir, pkg)) | ||
// if we detect a framework (with the same detection result in a path, instead of the root we need to remove it in the root) | ||
for (const result of results) { | ||
for (let i = 0, max = rootFrameworks.length; i < max; i++) { | ||
if (isEqual(result.detected, rootFrameworks[i].detected)) { | ||
rootFrameworkMutex.runExclusive(async () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you briefly explain why mutex is needed here (and only here)? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also - shouldn't this exclusive run be wider? index of item to remove is figured before entering exclusive mode and if mutex is here at all, presumably multiple concurrent runs can execute this code, so index figured out before acquiring mutex might no longer be correct because since figured out There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh that's a good point! yea because we run this highly in parallel so mutating the array is dangerous in a concurrent highly async loop! |
||
this.frameworks.set(pkg, rootFrameworks.splice(i, 1)) | ||
}) | ||
} | ||
} | ||
} | ||
this.frameworks.set(pkg, results) | ||
} | ||
}), | ||
) | ||
} else { | ||
this.frameworks.set('', await this.detectFrameworksInPath()) | ||
this.frameworks.set('', rootFrameworks) | ||
} | ||
|
||
await this.events.emit('detectFrameworks', this.frameworks) | ||
return [...this.frameworks.values()].flat() | ||
} catch (error) { | ||
|
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.
could the same be achieved with just
p-limit
using1
as concurrency? It seems likep-limit
is already indirect dependency ofbuild-info
:(no strong preference here, just checking)