@@ -4,6 +4,7 @@ using namespace System.Collections.Concurrent
44using namespace System.Collections.Generic
55using namespace System.Collections.Specialized
66using namespace System.IO
7+ using namespace System.IO.Compression
78using namespace System.IO.Pipelines
89using namespace System.Management.Automation
910using namespace System.Net
@@ -19,8 +20,6 @@ High Performance Powershell Module Installation
1920THIS IS NOT FOR PRODUCTION, it should be considered "Fragile" and has very little error handling and type safety
2021It also doesn't generate the PowershellGet XML files currently, so PowershellGet will see them as "External" modules
2122#>
22-
23-
2423function Install-ModuleFast {
2524 [CmdletBinding (SupportsShouldProcess )]
2625 param (
@@ -459,9 +458,6 @@ function Install-ModuleFastHelper {
459458 [Dictionary [Task , hashtable ]]$taskMap = @ {}
460459
461460 [List [Task [Stream ]]]$streamTasks = foreach ($module in $ModuleToInstall ) {
462- # TODO: GetStreamAsync then waitone on those and connect them to a file stream, then threadjob the install
463- # since zip is sync blocking
464- # TODO: Check file health and integrity. If it's good, skip the download and move on to install process.
465461 $context = @ {
466462 Module = $module
467463 DownloadPath = Join-Path $ModuleCache " $ ( $module.Name ) .$ ( $module.Version ) .nupkg"
@@ -470,7 +466,7 @@ function Install-ModuleFastHelper {
470466 $taskMap.Add ($fetchTask , $context )
471467 $fetchTask
472468 }
473-
469+ [ List [ Job2 ]] $installJobs = @ ()
474470 [List [Task ]]$downloadTasks = while ($streamTasks.count -gt 0 ) {
475471 $noTasksYetCompleted = -1
476472 [int ]$thisTaskIndex = [Task ]::WaitAny($streamTasks , 500 )
@@ -480,58 +476,21 @@ function Install-ModuleFastHelper {
480476 $context = $taskMap [$thisTask ]
481477 $context.fetchStream = $stream
482478 $streamTasks.RemoveAt ($thisTaskIndex )
483- $downloadPath = $context.DownloadPath
484- [void ][directory ]::CreateDirectory((Split-Path $downloadPath ))
485- $destFileStream = [file ]::Create($DownloadPath , 4096 , [FileOptions ]::SequentialScan)
486- $context.fileStream = $destFileStream
487- $downloadedTask = $stream.CopyToAsync ($destFileStream , $CancellationToken )
488- $taskMap.Add ($downloadedTask , $context )
489- $downloadedTask
490- }
491479
492- # Installation jobs are captured here, we will check them once all downloads have completed
493- [List [Job2 ]]$installJobs = @ ()
480+ # We are going to extract these straight out of memory, so we don't need to write the nupkg to disk
494481
495- $downloaded = 0
496- $downloadedProgressId = Get-Random
497- # TODO: Filestreams should be disposed in a try/catch in case of cancellation. In PS 7.3+, should be a clean() block
498- while ($downloadTasks.count -gt 0 ) {
499- # TODO: Check on in jobs and if there's a failure, cancel the rest of the jobs
500- $noTasksYetCompleted = -1
501- [int ]$thisTaskIndex = [Task ]::WaitAny($downloadTasks , 500 )
502- if ($thisTaskIndex -eq $noTasksYetCompleted ) { continue }
503- $thisTask = $downloadTasks [$thisTaskIndex ]
504- $context = $taskMap [$thisTask ]
505- # We can close these streams now that it is downloaded.
506- # This releases the lock on the file
507- # TODO: Maybe can connect the stream to a zip decompressionstream. Should be in cache so performance would be negligible
508- $context.fileStream.Dispose ()
509- $context.fetchStream.Dispose ()
510- # The file copy task is a void task that doesnt return anything, so we dont need to do GetResult()
511- $downloadTasks.RemoveAt ($thisTaskIndex )
512-
513- # Start a new threadjob to handle the installation, because the zipfile API is not async. Also extraction is
514- # CPU intensive so multiple threads will be helpful here and worth the startup cost of a runspace
515- $installJobParams = @ {
516- ScriptBlock = (Get-Item Function:\Install-ModuleFastOperation ).Scriptblock
517- # Named parameters require a hack so we will just do these in order
518- ArgumentList = @ (
519- $context.Module.Name ,
520- $context.Module.Version ,
521- $context.DownloadPath ,
522- $Destination
523- )
482+ $installPath = Join-Path $Destination $context.Module.Name $context.Module.Version
483+ Write-Verbose " Starting Extract Job $ ( $context.Module ) to $installPath "
484+ # This is a sync process and we want to do it in parallel, hence the threadjob
485+ $installJob = Start-ThreadJob - ThrottleLimit 8 {
486+ $zip = [IO.Compression.ZipArchive ]::new($USING :stream , ' Read' )
487+ [IO.Compression.ZipFileExtensions ]::ExtractToDirectory($zip , $USING :installPath )
488+ ($zip ).Dispose()
489+ ($USING :stream ).Dispose()
524490 }
525- Write-Debug " Starting Module Install Job for $ ( $context.Module ) "
526- $installJob = Start-ThreadJob @installJobParams
527491 $installJobs.Add ($installJob )
528- $downloaded ++
529- Write-Progress - Id $downloadedProgressId - ParentId 1 - Activity ' Download' - Status " $downloaded /$ ( $ModuleToInstall.count ) Modules" - PercentComplete ($downloaded / $ModuleToInstall.count * 100 )
530-
531492 }
532493
533- # TODO: Correlate the installjobs to a dictionary so we can return the original modulespec maybe?
534- # Or is that even needed?
535494 $installed = 0
536495 $installProgressId = (Get-Random )
537496 while ($installJobs.count -gt 0 ) {
@@ -542,6 +501,51 @@ function Install-ModuleFastHelper {
542501 $installed ++
543502 Write-Progress - Id $installProgressId - ParentId 1 - Activity ' Install' - Status " $installed /$ ( $ModuleToInstall.count ) Modules" - PercentComplete ($installed / $ModuleToInstall.count * 100 )
544503 }
504+
505+ # #Installation jobs are captured here, we will check them once all downloads have completed
506+
507+
508+ # $downloaded = 0
509+ # $downloadedProgressId = Get-Random
510+ # #TODO: Filestreams should be disposed in a try/catch in case of cancellation. In PS 7.3+, should be a clean() block
511+ # while ($downloadTasks.count -gt 0) {
512+ # #TODO: Check on in jobs and if there's a failure, cancel the rest of the jobs
513+ # $noTasksYetCompleted = -1
514+ # [int]$thisTaskIndex = [Task]::WaitAny($downloadTasks, 500)
515+ # if ($thisTaskIndex -eq $noTasksYetCompleted) { continue }
516+ # $thisTask = $downloadTasks[$thisTaskIndex]
517+ # $context = $taskMap[$thisTask]
518+ # # We can close these streams now that it is downloaded.
519+ # # This releases the lock on the file
520+ # #TODO: Maybe can connect the stream to a zip decompressionstream. Should be in cache so performance would be negligible
521+ # $context.fileStream.Dispose()
522+ # $context.fetchStream.Dispose()
523+ # #The file copy task is a void task that doesnt return anything, so we dont need to do GetResult()
524+ # $downloadTasks.RemoveAt($thisTaskIndex)
525+
526+ # #Start a new threadjob to handle the installation, because the zipfile API is not async. Also extraction is
527+ # #CPU intensive so multiple threads will be helpful here and worth the startup cost of a runspace
528+ # $installJobParams = @{
529+ # ScriptBlock = (Get-Item Function:\Install-ModuleFastOperation).Scriptblock
530+ # #Named parameters require a hack so we will just do these in order
531+ # ArgumentList = @(
532+ # $context.Module.Name,
533+ # $context.Module.Version,
534+ # $context.DownloadPath,
535+ # $Destination
536+ # )
537+ # }
538+ # Write-Debug "Starting Module Install Job for $($context.Module)"
539+ # $installJob = Start-ThreadJob @installJobParams
540+ # $installJobs.Add($installJob)
541+ # $downloaded++
542+ # Write-Progress -Id $downloadedProgressId -ParentId 1 -Activity 'Download' -Status "$downloaded/$($ModuleToInstall.count) Modules" -PercentComplete ($downloaded / $ModuleToInstall.count * 100)
543+
544+ # }
545+
546+ # #TODO: Correlate the installjobs to a dictionary so we can return the original modulespec maybe?
547+ # #Or is that even needed?
548+
545549}
546550
547551# This will be run inside a threadjob. We separate this so that we can test it independently
@@ -1103,7 +1107,7 @@ function Find-LocalModule {
11031107 # BUG: Prerelease Module paths are still not recognized by internal PS commands and can break things
11041108
11051109 # Search all psmodulepaths for the module
1106- $modulePaths = $env: PSModulePath -split ([Path ]::PathSeparator, [StringSplitOptions ]::RemoveEmptyEntries)
1110+ $modulePaths = $env: PSModulePath.Split ([Path ]::PathSeparator, [StringSplitOptions ]::RemoveEmptyEntries)
11071111 if (-Not $modulePaths ) {
11081112 Write-Warning ' No PSModulePaths found in $env:PSModulePath. If you are doing isolated testing you can disregard this.'
11091113 return
@@ -1221,6 +1225,7 @@ function ConvertTo-AuthenticationHeaderValue ([PSCredential]$Credential) {
12211225 )
12221226 return [Net.Http.Headers.AuthenticationHeaderValue ]::New(' Basic' , $basicCredential )
12231227}
1228+
12241229# endregion Helpers
12251230
12261231# Export-ModuleMember Get-ModuleFast
0 commit comments