The Curious Case of PowerShell Module Autoloading with Multiple Nested Modules

As part of a new project at work I wanted to move towards converting our PowerShell function libraries into PowerShell modules. After some discussion we decided that rather than having multiple functions within a singular .PS1 file (and dot sourcing to pull it in), we wanted one function per file and to pull those in using a module manifest. The obvious and more common alternative to this of course is having all your functions in a singular .PSM1 file. I’m aware that in the wider world feelings are split on this matter but for our environment the multiple file approach suits us better. According to the official Microsoft documentation, it is possible to create a Module Manifest with multiple “NestedModules” (i.e. the NestedModules parameter accepts an array). Apparently these can be .PSM1, .PS1 or .DLL files.

Using this approach however resulted in unusual behaviour; functions defined in the second and subsequent NestedModules do not autoload. The following example assumes that you have correctly modified $env:PSModulePath to include the location of your custom modules.

TestModule.psd1

#
# Module manifest for module 'TestModule'
#
# Generated by: RobinMalik
#
# Generated on: 06/10/2014
#

@{

# Script module or binary module file associated with this manifest.
# RootModule = ''

# Version number of this module.
ModuleVersion = '1.0'

# ID used to uniquely identify this module
GUID = 'd25fb6ba-cc7d-43b3-a5ed-0ddf9fefdae7'

# Author of this module
Author = 'RobinMalik'

# Company or vendor of this module
CompanyName = 'Unknown'

# Copyright statement for this module
Copyright = '(c) 2014 RobinMalik. All rights reserved.'

# Description of the functionality provided by this module
# Description = ''

# Minimum version of the Windows PowerShell engine required by this module
# PowerShellVersion = ''

# Name of the Windows PowerShell host required by this module
# PowerShellHostName = ''

# Minimum version of the Windows PowerShell host required by this module
# PowerShellHostVersion = ''

# Minimum version of Microsoft .NET Framework required by this module
# DotNetFrameworkVersion = ''

# Minimum version of the common language runtime (CLR) required by this module
# CLRVersion = ''

# Processor architecture (None, X86, Amd64) required by this module
# ProcessorArchitecture = ''

# Modules that must be imported into the global environment prior to importing this module
# RequiredModules = @()

# Assemblies that must be loaded prior to importing this module
# RequiredAssemblies = @()

# Script files (.ps1) that are run in the caller's environment prior to importing this module.
# ScriptsToProcess = @()

# Type files (.ps1xml) to be loaded when importing this module
# TypesToProcess = @()

# Format files (.ps1xml) to be loaded when importing this module
# FormatsToProcess = @()

# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
NestedModules = @('Mod1.psm1','Mod2.psm1')

# Functions to export from this module
FunctionsToExport = '*'

# Cmdlets to export from this module
CmdletsToExport = '*'

# Variables to export from this module
VariablesToExport = '*'

# Aliases to export from this module
AliasesToExport = '*'

# List of all modules packaged with this module
# ModuleList = @()

# List of all files packaged with this module
# FileList = @()

# Private data to pass to the module specified in RootModule/ModuleToProcess
# PrivateData = ''

# HelpInfo URI of this module
# HelpInfoURI = ''

# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
# DefaultCommandPrefix = ''

}

Mod1.psm1

function Get-Something
{
	<#
	.SYNOPSIS
	.DESCRIPTION
	.PARAMETER astring
	.EXAMPLE
	#>

	param (
		[string]$astring
	)	
	
	Write-Output "GET something"
}

Mod2.psm1

function Set-Something
{
	<#
	.SYNOPSIS
	.DESCRIPTION
	.PARAMETER astring
	.EXAMPLE
	#>

	param (
		[string]$astring
	)	
	
	Write-Output "SET something"
}

Both Get-Module -ListAvailable and Test-ModuleManifest -Path C:\CustomModules\TestModule\TestModule.psd1 show:

Directory: C:\CustomModules
ModuleType Version    Name                                ExportedCommands
---------- -------    ----                                ----------------
Manifest   1.0        TestModule                          Get-Something

As you can see, ExportedCommands only shows a function from Mod1.psm1 and the function available in Mod2.psm1 (Set-Something) is missing. As such tab completion / autocomplete / autoload only works for Get-Something. If I were to add an additional function to Mod1.psm1, we’d see an output like this:

Directory: C:\CustomModules
ModuleType Version    Name                                ExportedCommands
---------- -------    ----                                ----------------
Manifest   1.0        TestModule                          {Get-Something, Edit-Something}

… but again, nothing from Mod2.psm1.

What if we run Get-Command -Module TestModule?

CommandType     Name                                               ModuleName
-----------     ----                                               ----------
Function        Get-Something                                      TestModule
Function        Edit-Something                                     TestModule
Function        Set-Something                                      TestModule

Commands/functions from both modules appear and now Set-Something tab completes / autocompletes / autoloads. This persists across PowerShell sessions; if I close PowerShell, reopen it, and modify $env:PSModulePath I can still tab complete my Set-Something function from the second NestedModule. This is PowerShell command caching in action, and I have to give credit to this post by the extremely knowledgeable Dr Tobias Weltner (which references “Module Command Discovery” and a “Secret Command Cache”) for helping me discover this. Unfortunately there’s very little information online about PowerShell caching so I set about hunting for this magical secret cache and came across a folder called “CommandAnalysis” located at “C:\Users\Username\AppData\Local\Microsoft\Windows\PowerShell\CommandAnalysis”.

Closing PowerShell, renaming this folder, and reopening PowerShell results in this folder being recreated. On my system a total of 79 files are created (PowerShell_AnalysisCacheIndex and many files prefixed with PowerShell_AnalysisCacheEntry_). After I update $env:PSModulePath and run Get-Module -ListAvailable an additional 18 files are created, and as expected commands from Mod1.psm1 of my custom module tab complete.

As well as Get-Command -Module TestModule seeming to force a parse of the Mod2.psm1 and make the functions available and persist across sessions, the same happens if you explicitly load the module (but this defeats the purpose of the autoload feature?). It’s worth noting that testing shows this cache to have a TTL value; I explicitly imported the module, was able to execute Set-Something, and was able to do this between PowerShell sessions but upon reopening PowerShell the following morning I was not.

I also noticed that if I were to call a function from the first nested module (Mod1.psm1), this would also result in functions from the second nested module being available, but this time it was only for that session.

I have tried using .PS1 files instead of .PSM1 and the behaviour is the same. I’ve also tried using the following:
TestModule.psd1

NestedModules = @('TestModule.psm1')

TestModule.psm1

gci $psscriptroot\*.ps1 | % { . $_.FullName }

.. but this is worse, with no commands being exported.

Including Export-ModuleMember in each PSM1 file doesn’t help.

It is possible to include scripts in the module manifest on the ScriptsToProcess line and this seems to solve the problem, but I suspect the entire code is read into memory rather than the command names being cached. This also doesn’t addresses the original problem.

Just for full disclosure, here is a dump of my $PSVersionTable variable:

Name                           Value
----                           -----
PSVersion                      4.0
WSManStackVersion              3.0
SerializationVersion           1.1.0.1
CLRVersion                     4.0.30319.34014
BuildVersion                   6.3.9600.17090
PSCompatibleVersions           {1.0, 2.0, 3.0, 4.0}
PSRemotingProtocolVersion      2.2

There is a discussion I’m involved in on Stack Overflow around this issue. As I was not the originator of the post, I decided to write this blog article to clarify what I’d observed and tried (as well as being free to insert code which you cannot easily do in the comments section).