Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dbadf8b
Add &! ThreadJob background operator support
Copilot Jan 1, 2026
ba401e6
Add tests for ThreadJob background operator
Copilot Jan 1, 2026
b4fd925
Add exception handling for Start-ThreadJob cmdlet lookup
Copilot Jan 1, 2026
16e6070
Use specific exception types in catch blocks for Start-ThreadJob lookup
Copilot Jan 1, 2026
023709a
Fix compilation errors: move BackgroundThreadJob to ChainableAst and …
Copilot Jan 2, 2026
332e423
Move BackgroundThreadJob property to PipelineBaseAst base class
Copilot Jan 2, 2026
523b7cf
Address PR review feedback: improve tests and simplify exception hand…
Copilot Mar 11, 2026
3c8b2d6
Fix binary-breaking change: move AmpersandExclaim token to end of enum
Copilot Mar 11, 2026
729cf1b
Remove accidentally committed backup file
Copilot Mar 11, 2026
2dedf53
Fix parser error with &! operator - set flags correctly
Copilot Mar 11, 2026
88eb01e
Fix &! operator recognition in PipelineRule switch statement
Copilot Mar 11, 2026
8c20d35
Fix missing backgroundThreadJob variable declaration in PipelineRule
Copilot Mar 11, 2026
24ae465
Fix ThreadJob detection by using GetCommand instead of GetCmdlet
Copilot Mar 11, 2026
b19bf52
Fix compilation error: properly handle CommandInfo type checking
Copilot Mar 11, 2026
d63f1c7
Fix WorkingDirectory parameter error with Start-ThreadJob
Copilot Mar 11, 2026
22b3538
Fix script block literal execution with &! operator
Copilot Mar 11, 2026
c51022a
Fix compilation error: cast PipelineBaseAst to PipelineAst before acc…
Copilot Mar 11, 2026
4a7f57f
Fix BackgroundThreadJob property not set on PipelineChainAst
Copilot Mar 11, 2026
dddf371
Apply review feedback: fix TypeNames checks, add try/finally cleanup,…
Copilot Mar 11, 2026
cf4ffb8
Fix parser CommandRule, scriptblock body extraction, and ThreadJob cm…
Copilot Mar 11, 2026
6e093f5
Initial plan
Copilot Mar 12, 2026
30d7450
Apply PR review feedback: use GetScriptBlock() and HashSet for magic …
Copilot Mar 12, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,8 @@ public static PSTokenType GetPSTokenType(Token token)
/* Hidden */ PSTokenType.Keyword,
/* Base */ PSTokenType.Keyword,
/* Default */ PSTokenType.Keyword,
/* Clean */ PSTokenType.Keyword,
/* AmpersandExclaim */ PSTokenType.Operator,

#endregion Flags for keywords

Expand Down
49 changes: 47 additions & 2 deletions src/System.Management.Automation/engine/parser/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5346,6 +5346,7 @@ private StatementAst FunctionDeclarationRule(Token functionToken)
case TokenKind.AndAnd:
case TokenKind.OrOr:
case TokenKind.Ampersand:
case TokenKind.AmpersandExclaim:
case TokenKind.Variable:
case TokenKind.SplattedVariable:
case TokenKind.HereStringExpandable:
Expand Down Expand Up @@ -5849,6 +5850,7 @@ private PipelineBaseAst PipelineChainRule()
Token currentChainOperatorToken = null;
Token nextToken = null;
bool background = false;
bool backgroundThreadJob = false;
while (true)
{
// Look for the next pipeline in the chain,
Expand Down Expand Up @@ -5938,6 +5940,24 @@ private PipelineBaseAst PipelineChainRule()
background = true;
goto default;

// ThreadJob background operator
case TokenKind.AmpersandExclaim:
SkipToken();
nextToken = PeekToken();

switch (nextToken.Kind)
{
case TokenKind.AndAnd:
case TokenKind.OrOr:
SkipToken();
ReportError(nextToken.Extent, nameof(ParserStrings.BackgroundOperatorInPipelineChain), ParserStrings.BackgroundOperatorInPipelineChain);
return new ErrorStatementAst(ExtentOf(currentPipelineChain ?? nextPipeline, nextToken.Extent));
}

background = true;
backgroundThreadJob = true;
goto default;

// No more chain operators -- return
default:
// If we haven't seen a chain yet, pass through the pipeline
Expand All @@ -5951,15 +5971,18 @@ private PipelineBaseAst PipelineChainRule()

// Set background on the pipeline AST
nextPipeline.Background = true;
nextPipeline.BackgroundThreadJob = backgroundThreadJob;
return nextPipeline;
}

return new PipelineChainAst(
var chainAst = new PipelineChainAst(
ExtentOf(currentPipelineChain.Extent, nextPipeline.Extent),
currentPipelineChain,
nextPipeline,
currentChainOperatorToken.Kind,
background);
chainAst.BackgroundThreadJob = backgroundThreadJob;
return chainAst;
}

// Assemble the new chain statement AST
Expand Down Expand Up @@ -6008,6 +6031,7 @@ private PipelineBaseAst PipelineRule(
Token nextToken = null;
bool scanning = true;
bool background = false;
bool backgroundThreadJob = false;
ExpressionAst expr = startExpression;
while (scanning)
{
Expand Down Expand Up @@ -6125,6 +6149,20 @@ private PipelineBaseAst PipelineRule(
background = true;
break;

case TokenKind.AmpersandExclaim:
if (!allowBackground)
{
// Handled by invoking rule
scanning = false;
continue;
}

SkipToken();
scanning = false;
background = true;
backgroundThreadJob = true;
break;

case TokenKind.Pipe:
SkipToken();
SkipNewlines();
Expand Down Expand Up @@ -6156,7 +6194,12 @@ private PipelineBaseAst PipelineRule(
return null;
}

return new PipelineAst(ExtentOf(startExtent, pipelineElements[pipelineElements.Count - 1]), pipelineElements, background);
var pipeline = new PipelineAst(ExtentOf(startExtent, pipelineElements[pipelineElements.Count - 1]), pipelineElements, background);
if (backgroundThreadJob)
{
pipeline.BackgroundThreadJob = true;
}
return pipeline;
}

private RedirectionAst RedirectionRule(RedirectionToken redirectionToken, RedirectionAst[] redirections, ref IScriptExtent extent)
Expand Down Expand Up @@ -6316,6 +6359,7 @@ private ExpressionAst GetCommandArgument(CommandArgumentContext context, Token t
case TokenKind.AndAnd:
case TokenKind.OrOr:
case TokenKind.Ampersand:
case TokenKind.AmpersandExclaim:
case TokenKind.MinusMinus:
case TokenKind.Comma:
UngetToken(token);
Expand Down Expand Up @@ -6520,6 +6564,7 @@ internal Ast CommandRule(bool forDynamicKeyword)
case TokenKind.AndAnd:
case TokenKind.OrOr:
case TokenKind.Ampersand:
case TokenKind.AmpersandExclaim:
UngetToken(token);
scanning = false;
continue;
Expand Down
13 changes: 11 additions & 2 deletions src/System.Management.Automation/engine/parser/ast.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5610,7 +5610,9 @@ public PipelineChainAst(
/// </returns>
public override Ast Copy()
{
return new PipelineChainAst(Extent, CopyElement(LhsPipelineChain), CopyElement(RhsPipeline), Operator, Background);
var copy = new PipelineChainAst(Extent, CopyElement(LhsPipelineChain), CopyElement(RhsPipeline), Operator, Background);
copy.BackgroundThreadJob = this.BackgroundThreadJob;
return copy;
}

internal override object Accept(ICustomAstVisitor visitor)
Expand Down Expand Up @@ -5675,6 +5677,11 @@ public virtual ExpressionAst GetPureExpression()
{
return null;
}

/// <summary>
/// Indicates that this pipeline should be run in the background as a ThreadJob.
/// </summary>
public bool BackgroundThreadJob { get; internal set; }
}

/// <summary>
Expand Down Expand Up @@ -5793,7 +5800,9 @@ public override ExpressionAst GetPureExpression()
public override Ast Copy()
{
var newPipelineElements = CopyElements(this.PipelineElements);
return new PipelineAst(this.Extent, newPipelineElements, this.Background);
var copy = new PipelineAst(this.Extent, newPipelineElements, this.Background);
copy.BackgroundThreadJob = this.BackgroundThreadJob;
return copy;
}

#region Visitors
Expand Down
9 changes: 7 additions & 2 deletions src/System.Management.Automation/engine/parser/token.cs
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,9 @@ public enum TokenKind
/// <summary>The 'clean' keyword.</summary>
Clean = 170,

/// <summary>The ThreadJob background operator '&!'.</summary>
AmpersandExclaim = 171,

#endregion Keywords
}

Expand Down Expand Up @@ -952,6 +955,7 @@ public static class TokenTraits
/* Base */ TokenFlags.Keyword,
/* Default */ TokenFlags.Keyword,
/* Clean */ TokenFlags.Keyword | TokenFlags.ScriptBlockBlockName,
/* AmpersandExclaim */ TokenFlags.SpecialOperator | TokenFlags.ParseModeInvariant,

#endregion Flags for keywords
};
Expand Down Expand Up @@ -1152,6 +1156,7 @@ public static class TokenTraits
/* Base */ "base",
/* Default */ "default",
/* Clean */ "clean",
/* AmpersandExclaim */ "&!",

#endregion Text for keywords
};
Expand All @@ -1160,10 +1165,10 @@ public static class TokenTraits
static TokenTraits()
{
Diagnostics.Assert(
s_staticTokenFlags.Length == ((int)TokenKind.Clean + 1),
s_staticTokenFlags.Length == ((int)TokenKind.AmpersandExclaim + 1),
"Table size out of sync with enum - _staticTokenFlags");
Diagnostics.Assert(
s_tokenText.Length == ((int)TokenKind.Clean + 1),
s_tokenText.Length == ((int)TokenKind.AmpersandExclaim + 1),
"Table size out of sync with enum - _tokenText");
// Some random assertions to make sure the enum and the traits are in sync
Diagnostics.Assert(GetTraits(TokenKind.Begin) == (TokenFlags.Keyword | TokenFlags.ScriptBlockBlockName),
Expand Down
9 changes: 8 additions & 1 deletion src/System.Management.Automation/engine/parser/tokenizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4975,12 +4975,19 @@ internal Token NextToken()
return ScanNumber(c);

case '&':
if (PeekChar() == '&')
c1 = PeekChar();
if (c1 == '&')
{
SkipChar();
return NewToken(TokenKind.AndAnd);
}

if (c1 == '!')
{
SkipChar();
return NewToken(TokenKind.AmpersandExclaim);
}

return NewToken(TokenKind.Ampersand);

case '|':
Expand Down
137 changes: 101 additions & 36 deletions src/System.Management.Automation/engine/runtime/Operations/MiscOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ namespace System.Management.Automation
{
internal static class PipelineOps
{
// PowerShell magic/automatic variable names that should not be auto-prefixed with $using:
// when constructing a background job script block for the &! operator.
private static readonly HashSet<string> s_backgroundJobMagicVariables = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"PID", "PSVersionTable", "PSEdition", "PSHOME", "HOST", "TRUE", "FALSE", "NULL"
};

private static CommandProcessorBase AddCommand(PipelineProcessor pipe,
CommandParameterInternal[] commandElements,
CommandBaseAst commandBaseAst,
Expand Down Expand Up @@ -550,51 +557,110 @@ internal static void InvokePipelineInBackground(
CommandProcessorBase commandProcessor = null;

// For background jobs rewrite the pipeline as a Start-Job command
var scriptblockBodyString = pipelineAst.Extent.Text;
var pipelineOffset = pipelineAst.Extent.StartOffset;
var variables = pipelineAst.FindAll(static x => x is VariableExpressionAst, true);

// Minimize allocations by initializing the stringbuilder to the size of the source string + space for ${using:} * 2
System.Text.StringBuilder updatedScriptblock = new System.Text.StringBuilder(scriptblockBodyString.Length + 18);
int position = 0;

// Prefix variables in the scriptblock with $using:
foreach (var v in variables)
ScriptBlock sb;

// Check if the pipeline is already a script block expression (e.g., {1+1} &!)
// In this case, we should use the script block directly instead of wrapping it
// Note: PipelineElements is only available on PipelineAst, not PipelineBaseAst
var scriptBlockExpr = pipelineAst is PipelineAst pipeline &&
pipeline.PipelineElements.Count == 1 &&
pipeline.PipelineElements[0] is CommandExpressionAst cmdExpr &&
cmdExpr.Expression is ScriptBlockExpressionAst sbExpr
? sbExpr
: null;

if (scriptBlockExpr != null)
{
// The pipeline is already a script block - use the ScriptBlock from the AST directly.
// Using ScriptBlock.Create("{ content }") would create a script that returns a ScriptBlock
// object rather than executing the body. Getting the ScriptBlock from the ScriptBlockAst
// avoids all text manipulation and correctly executes the body.
// Users are expected to use explicit $using: scoping in their scriptblock to capture
// outer variables (e.g., { $using:testVar } &!).
sb = scriptBlockExpr.ScriptBlock.GetScriptBlock();
}
else
{
var variableName = ((VariableExpressionAst)v).VariablePath.UserPath;
// The pipeline is a regular command - wrap it in a script block.
// Auto-inject $using: for variables that exist in the current scope.
var scriptblockBodyString = pipelineAst.Extent.Text;
var pipelineOffset = pipelineAst.Extent.StartOffset;
var variables = pipelineAst.FindAll(static x => x is VariableExpressionAst, true);

// Skip variables that don't exist
if (funcContext._executionContext.EngineSessionState.GetVariable(variableName) == null)
// Minimize allocations by initializing the stringbuilder to the size of the source string + space for ${using:} * 2
System.Text.StringBuilder updatedScriptblock = new System.Text.StringBuilder(scriptblockBodyString.Length + 18);
int position = 0;

// Prefix variables in the scriptblock with $using:
foreach (var v in variables)
{
continue;
var variableName = ((VariableExpressionAst)v).VariablePath.UserPath;

// Skip variables that don't exist
if (funcContext._executionContext.EngineSessionState.GetVariable(variableName) == null)
{
continue;
}

// Strip global: prefix if present, then check against magic variable names
var cleanVariableName = variableName.StartsWith("global:", StringComparison.OrdinalIgnoreCase)
? variableName.Substring(7)
: variableName;

if (!s_backgroundJobMagicVariables.Contains(cleanVariableName))
{
updatedScriptblock.Append(scriptblockBodyString.AsSpan(position, v.Extent.StartOffset - pipelineOffset - position));
updatedScriptblock.Append("${using:");
updatedScriptblock.Append(CodeGeneration.EscapeVariableName(variableName));
updatedScriptblock.Append('}');
position = v.Extent.EndOffset - pipelineOffset;
}
}

// Skip PowerShell magic variables
if (!Regex.Match(
variableName,
"^(global:){0,1}(PID|PSVersionTable|PSEdition|PSHOME|HOST|TRUE|FALSE|NULL)$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant).Success)
updatedScriptblock.Append(scriptblockBodyString.AsSpan(position));
sb = ScriptBlock.Create(updatedScriptblock.ToString());
}

// Use Start-ThreadJob if BackgroundThreadJob is set, otherwise use Start-Job
CmdletInfo commandInfo;
bool usingThreadJob = false;
if (pipelineAst.BackgroundThreadJob)
{
// Use CommandTypes.Cmdlet only to avoid resolving a user-defined function that
// shadows the real Start-ThreadJob cmdlet, which would cause &! to silently fall
// back to Start-Job even when the ThreadJob module is installed.
var threadJobCmdlet = context.SessionState.InvokeCommand.GetCommand("Start-ThreadJob", CommandTypes.Cmdlet) as CmdletInfo;
if (threadJobCmdlet != null)
{
commandInfo = threadJobCmdlet;
usingThreadJob = true;
}
else
{
updatedScriptblock.Append(scriptblockBodyString.AsSpan(position, v.Extent.StartOffset - pipelineOffset - position));
updatedScriptblock.Append("${using:");
updatedScriptblock.Append(CodeGeneration.EscapeVariableName(variableName));
updatedScriptblock.Append('}');
position = v.Extent.EndOffset - pipelineOffset;
// Fall back to Start-Job if Start-ThreadJob cmdlet is not available
commandInfo = new CmdletInfo("Start-Job", typeof(StartJobCommand));
}
}

updatedScriptblock.Append(scriptblockBodyString.AsSpan(position));
var sb = ScriptBlock.Create(updatedScriptblock.ToString());
var commandInfo = new CmdletInfo("Start-Job", typeof(StartJobCommand));
else
{
commandInfo = new CmdletInfo("Start-Job", typeof(StartJobCommand));
}

commandProcessor = context.CommandDiscovery.LookupCommandProcessor(commandInfo, CommandOrigin.Internal, false, context.EngineSessionState);

var workingDirectoryParameter = CommandParameterInternal.CreateParameterWithArgument(
parameterAst: pipelineAst,
parameterName: "WorkingDirectory",
parameterText: null,
argumentAst: pipelineAst,
value: context.SessionState.Path.CurrentLocation.Path,
spaceAfterParameter: false);
// Only add WorkingDirectory parameter for Start-Job, not for Start-ThreadJob
// Start-ThreadJob doesn't support the WorkingDirectory parameter
if (!usingThreadJob)
{
var workingDirectoryParameter = CommandParameterInternal.CreateParameterWithArgument(
parameterAst: pipelineAst,
parameterName: "WorkingDirectory",
parameterText: null,
argumentAst: pipelineAst,
value: context.SessionState.Path.CurrentLocation.Path,
spaceAfterParameter: false);
commandProcessor.AddParameter(workingDirectoryParameter);
}

var scriptBlockParameter = CommandParameterInternal.CreateParameterWithArgument(
parameterAst: pipelineAst,
Expand All @@ -604,7 +670,6 @@ internal static void InvokePipelineInBackground(
value: sb,
spaceAfterParameter: false);

commandProcessor.AddParameter(workingDirectoryParameter);
commandProcessor.AddParameter(scriptBlockParameter);
pipelineProcessor.Add(commandProcessor);
pipelineProcessor.LinkPipelineSuccessOutput(outputPipe ?? new Pipe(new List<object>()));
Expand Down
Loading