Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ outputs/*
bin/
obj/
libs/

# Generated performance measurements
scripts/perf-results.jsonl
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,18 @@ When you run `Install-NerdFont` again without `-Force`, fonts that are already i
Cache locations:

- Windows: `%LOCALAPPDATA%/PSModule/NerdFonts/cache`
- macOS and Linux: `$HOME/.cache/PSModule/NerdFonts`
- macOS: `$HOME/Library/Caches/PSModule/NerdFonts`
- Linux: `$XDG_CACHE_HOME/PSModule/NerdFonts` when `XDG_CACHE_HOME` is set, otherwise `$HOME/.cache/PSModule/NerdFonts`

You can inspect the active cache path in PowerShell with:

```powershell
if ($IsWindows) {
Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'PSModule/NerdFonts/cache'
} elseif ($IsMacOS) {
Join-Path $HOME 'Library/Caches/PSModule/NerdFonts'
} elseif (-not [string]::IsNullOrWhiteSpace($env:XDG_CACHE_HOME)) {
Join-Path $env:XDG_CACHE_HOME 'PSModule/NerdFonts'
} else {
Join-Path $HOME '.cache/PSModule/NerdFonts'
}
Expand Down
165 changes: 165 additions & 0 deletions scripts/Measure-InstallPerformance.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<#
.SYNOPSIS
Measures Install-NerdFont performance across known scenarios.

.DESCRIPTION
Runs timed installation scenarios and appends structured results to a JSON Lines file.
Each fresh-install scenario removes its target fonts during setup. The already-installed
scenario explicitly installs its subset before measuring the skip path.

.EXAMPLE
./Measure-InstallPerformance.ps1 -Iteration 'baseline' -Subset 'Hack', 'FiraCode', 'JetBrainsMono'
#>
[CmdletBinding()]
param(
# Free-form label for the iteration, such as a module version or commit SHA.
[Parameter(Mandatory)]
[string] $Iteration,

# Named fonts used for the small subset scenarios.
[Parameter()]
[string[]] $Subset = @('Hack', 'FiraCode', 'JetBrainsMono'),

# Runs a full Install-NerdFont -All measurement.
[Parameter()]
[switch] $IncludeAll,

# File to receive one JSON result object per scenario.
[Parameter()]
[string] $ResultsPath = (Join-Path -Path $PSScriptRoot -ChildPath 'perf-results.jsonl')
)

$ErrorActionPreference = 'Stop'

function Remove-NerdFont {
<#
.SYNOPSIS
Removes the installed families associated with the supplied archive names.
#>
[OutputType([void])]
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string[]] $Names
)

foreach ($name in $Names) {
$normalizedName = $name -replace '[\s_-]', ''
$families = Get-Font -Scope CurrentUser | Where-Object {
$normalizedFamily = $_.Name -replace '[\s_-]', ''
$normalizedFamily -like "${normalizedName}*NerdFont*"
}
foreach ($family in $families) {
if ($PSCmdlet.ShouldProcess($family.Name, 'Uninstall font')) {
Uninstall-Font -Name $family.Name -Scope CurrentUser -ErrorAction Stop
}
}
}
}

function Remove-AllNerdFont {
<#
.SYNOPSIS
Removes all installed Nerd Font families.
#>
[OutputType([void])]
[CmdletBinding(SupportsShouldProcess)]
param()

$removeParams = @{
Names = (Get-NerdFont).Name
WhatIf = $WhatIfPreference
Confirm = $false
}
Remove-NerdFont @removeParams
}

function Measure-InstallScenario {
<#
.SYNOPSIS
Measures one setup and action pair.
#>
[OutputType([pscustomobject])]
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Name,

[Parameter(Mandatory)]
[scriptblock] $Setup,

[Parameter(Mandatory)]
[scriptblock] $Action,

[Parameter(Mandatory)]
[string] $ResultsPath
)

Write-Verbose "[$Iteration] Setup : $Name"
$null = & $Setup
[GC]::Collect()
[GC]::WaitForPendingFinalizers()

Write-Verbose "[$Iteration] Measure : $Name"
$stopwatch = [Diagnostics.Stopwatch]::StartNew()
$errorMessage = $null
try {
$null = & $Action
} catch {
$errorMessage = $_.ToString()
}
$stopwatch.Stop()

$result = [pscustomobject]@{
Iteration = $Iteration
Scenario = $Name
DurationMs = [int] $stopwatch.Elapsed.TotalMilliseconds
DurationS = [Math]::Round($stopwatch.Elapsed.TotalSeconds, 2)
Timestamp = (Get-Date).ToString('o')
Error = $errorMessage
Module = (Get-Module -Name NerdFonts).Version.ToString()
}

Write-Verbose "[$Iteration] Result : $Name -> $($result.DurationS)s"
$result | ConvertTo-Json -Compress | Add-Content -Path $ResultsPath
return $result
}

$results = [System.Collections.Generic.List[object]]::new()

$singleFontScenario = @{
Name = 'Single-Hack'
Setup = { Remove-NerdFont -Names 'Hack' -Confirm:$false }
Action = { Install-NerdFont -Name 'Hack' -Scope CurrentUser -Force }
ResultsPath = $ResultsPath
}
$results.Add((Measure-InstallScenario @singleFontScenario))

$subsetScenario = @{
Name = "Subset-$($Subset -join '+')"
Setup = { Remove-NerdFont -Names $Subset -Confirm:$false }
Action = { Install-NerdFont -Name $Subset -Scope CurrentUser -Force }
ResultsPath = $ResultsPath
}
$results.Add((Measure-InstallScenario @subsetScenario))

$alreadyInstalledScenario = @{
Name = 'Subset-AlreadyInstalled'
Setup = { Install-NerdFont -Name $Subset -Scope CurrentUser }
Action = { Install-NerdFont -Name $Subset -Scope CurrentUser }
ResultsPath = $ResultsPath
}
$results.Add((Measure-InstallScenario @alreadyInstalledScenario))

if ($IncludeAll) {
$allScenario = @{
Name = 'All'
Setup = { Remove-AllNerdFont -Confirm:$false }
Action = { Install-NerdFont -All -Scope CurrentUser -Force }
ResultsPath = $ResultsPath
}
$results.Add((Measure-InstallScenario @allScenario))
}

Write-Verbose "Summary for iteration '$Iteration':"
$results | Format-Table Iteration, Scenario, DurationS, Module -AutoSize
23 changes: 23 additions & 0 deletions src/functions/private/Get-NerdFontCacheRoot.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
function Get-NerdFontCacheRoot {
<#
.SYNOPSIS
Gets the platform-standard archive cache location.
#>
[OutputType([string])]
[CmdletBinding()]
param()

if ($IsWindows) {
return Join-Path -Path ([Environment]::GetFolderPath('LocalApplicationData')) -ChildPath 'PSModule/NerdFonts/cache'
}

if ($IsMacOS) {
return Join-Path -Path $HOME -ChildPath 'Library/Caches/PSModule/NerdFonts'
}

if (-not [string]::IsNullOrWhiteSpace($env:XDG_CACHE_HOME)) {
return Join-Path -Path $env:XDG_CACHE_HOME -ChildPath 'PSModule/NerdFonts'
}

return Join-Path -Path $HOME -ChildPath '.cache/PSModule/NerdFonts'
}
114 changes: 114 additions & 0 deletions src/functions/private/Invoke-NerdFontDownload.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
function Invoke-NerdFontDownload {
<#
.SYNOPSIS
Streams a font archive to disk with retries.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '',
Justification = 'Install-NerdFont confirms the download operation before invoking this helper.'
)]
[OutputType([void])]
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[uri] $Uri,

[Parameter(Mandatory)]
[string] $DestinationPath,

[Parameter()]
[System.Net.Http.HttpClient] $HttpClient,

[Parameter()]
[ValidateRange(1, 3600)]
[int] $AttemptTimeoutSeconds = 900
)

$maximumRetryCount = 5
$retryIntervalSeconds = 5
$temporaryPath = "$DestinationPath.$PID.tmp"
$ownsHttpClient = $null -eq $HttpClient
if ($ownsHttpClient) {
$HttpClient = New-NerdFontHttpClient -MaximumConnections 1
}

try {
for ($attempt = 0; $attempt -le $maximumRetryCount; $attempt++) {
$response = $null
$source = $null
$destination = $null
$cancellationTokenSource = [System.Threading.CancellationTokenSource]::new()
try {
$attemptTimeout = [TimeSpan]::FromSeconds($AttemptTimeoutSeconds)
$cancellationTokenSource.CancelAfter($attemptTimeout)
$cancellationToken = $cancellationTokenSource.Token
$responseHeadersOnly = [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead
$response = $httpClient.GetAsync(
$Uri,
$responseHeadersOnly,
$cancellationToken
).GetAwaiter().GetResult()

if (-not $response.IsSuccessStatusCode) {
$statusCode = [int] $response.StatusCode
$isTransientStatus = $statusCode -eq 408 -or $statusCode -eq 429 -or $statusCode -ge 500
if ($isTransientStatus -and $attempt -lt $maximumRetryCount) {
Start-Sleep -Seconds $retryIntervalSeconds
continue
}

$errorMessage = "Download failed with HTTP status code [$statusCode]."
throw [InvalidOperationException]::new($errorMessage)
}

$source = $response.Content.ReadAsStreamAsync($cancellationToken).GetAwaiter().GetResult()
$destination = [System.IO.FileStream]::new(
$temporaryPath,
[System.IO.FileMode]::Create,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None,
81920,
[System.IO.FileOptions]::Asynchronous
)
$null = $source.CopyToAsync($destination, 81920, $cancellationToken).GetAwaiter().GetResult()
$null = $destination.FlushAsync($cancellationToken).GetAwaiter().GetResult()
$destination.Dispose()
$destination = $null
$source.Dispose()
$source = $null
[System.IO.File]::Move($temporaryPath, $DestinationPath, $true)
return
} catch {
$isTransientException = @(
$_.Exception -is [System.Net.Http.HttpRequestException]
$_.Exception -is [System.IO.IOException]
$_.Exception -is [System.OperationCanceledException]
) -contains $true
if ($isTransientException -and $attempt -lt $maximumRetryCount) {
Start-Sleep -Seconds $retryIntervalSeconds
continue
}

throw
} finally {
if ($destination) {
$destination.Dispose()
}
if ($source) {
$source.Dispose()
}
if ($response) {
$response.Dispose()
}
$cancellationTokenSource.Dispose()
}
}
} finally {
if ($ownsHttpClient) {
$HttpClient.Dispose()
}
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item -LiteralPath $temporaryPath -Force -ErrorAction SilentlyContinue
}
}
}
34 changes: 34 additions & 0 deletions src/functions/private/New-NerdFontDownloadRunspacePool.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function New-NerdFontDownloadRunspacePool {
<#
.SYNOPSIS
Creates a bounded pool for concurrent archive downloads.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '',
Justification = 'Creating an in-memory runspace pool has no external side effects.'
)]
[OutputType([System.Management.Automation.Runspaces.RunspacePool])]
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateRange(1, [int]::MaxValue)]
[int] $MaximumRunspaces
)

$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2()
$downloadDefinition = ${function:Invoke-NerdFontDownload}.ToString()
$downloadFunction = [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new(
'Invoke-NerdFontDownload',
$downloadDefinition
)
$initialSessionState.Commands.Add($downloadFunction)

$runspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(
1,
$MaximumRunspaces,
$initialSessionState,
$Host
)
$runspacePool.Open()
return $runspacePool
}
25 changes: 25 additions & 0 deletions src/functions/private/New-NerdFontHttpClient.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
function New-NerdFontHttpClient {
<#
.SYNOPSIS
Creates a reusable HTTP client for an installation operation.
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
'PSUseShouldProcessForStateChangingFunctions', '',
Justification = 'Creating an in-memory HTTP client has no external side effects.'
)]
[OutputType([System.Net.Http.HttpClient])]
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateRange(1, [int]::MaxValue)]
[int] $MaximumConnections
)

$handler = [System.Net.Http.SocketsHttpHandler]::new()
$handler.MaxConnectionsPerServer = $MaximumConnections
$handler.PooledConnectionLifetime = [TimeSpan]::FromMinutes(15)

$httpClient = [System.Net.Http.HttpClient]::new($handler, $true)
$httpClient.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
return $httpClient
}
Loading
Loading