-
Notifications
You must be signed in to change notification settings - Fork 1
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
Change ShellCommand default behaviour not to swallow Cancellation exception #134
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,19 @@ | ||
using System; | ||
|
||
namespace Octopus.Shellfish; | ||
|
||
public enum ShellCommandOptions | ||
{ | ||
/// <summary> | ||
/// Default value, equivalent to not specifying any options. | ||
/// </summary> | ||
None = 0, | ||
|
||
/// <summary> | ||
/// By default, if the CancellationToken is cancelled, the running process will be killed, and an OperationCanceledException | ||
/// will be thrown, like the vast majority of other .NET code. | ||
/// However, the legacy ShellExecutor API would swallow OperationCanceledException exceptions on cancellation, so this | ||
/// option exists to preserve that behaviour where necessary. | ||
/// </summary> | ||
DoNotThrowOnCancellation, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,8 +12,12 @@ Octopus.Shellfish.ShellCommand.WithCredentials | |
Octopus.Shellfish.ShellCommand.WithOutputEncoding | ||
Octopus.Shellfish.ShellCommand.WithStdOutTarget | ||
Octopus.Shellfish.ShellCommand.WithStdErrTarget | ||
Octopus.Shellfish.ShellCommand.WithOptions | ||
Octopus.Shellfish.ShellCommand.Execute | ||
Octopus.Shellfish.ShellCommand.ExecuteAsync | ||
Octopus.Shellfish.ShellCommandOptions.value__ | ||
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. What's with this 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. That's how enums work in C#, if you decompile it in sharplab you get this
The reflection-scanny thing is obviously picking it up. It's just a convention test so 🤷 |
||
Octopus.Shellfish.ShellCommandOptions.None | ||
Octopus.Shellfish.ShellCommandOptions.DoNotThrowOnCancellation | ||
Octopus.Shellfish.ShellCommandResult.ExitCode | ||
Octopus.Shellfish.ShellExecutionException.Errors | ||
Octopus.Shellfish.ShellExecutionException.Message | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -225,6 +225,60 @@ read name | |
}) | ||
.WithStdErrTarget(stdErr); | ||
|
||
var cancellationToken = cts.Token; | ||
if (behaviour == SyncBehaviour.Async) | ||
{ | ||
await executor.Invoking(e => e.ExecuteAsync(cancellationToken)).Should().ThrowAsync<OperationCanceledException>(); | ||
} | ||
else | ||
{ | ||
executor.Invoking(e => e.Execute(cancellationToken)).Should().Throw<OperationCanceledException>(); | ||
} | ||
|
||
// we can't observe any exit code because Execute() threw an exception | ||
stdErr.ToString().Should().BeEmpty("no messages should be written to stderr"); | ||
stdOut.ToString().Should().BeOneOf([ | ||
"Enter Name:" + Environment.NewLine, | ||
"Enter Name:" + Environment.NewLine + "Hello ''" + Environment.NewLine, | ||
], because: "When we cancel the process we close StdIn and it shuts down. The process observes the EOF as empty string and prints 'Hello ' but there is a benign race condition which means we may not observe this output. Test needs to handle both cases"); | ||
} | ||
|
||
[Theory, InlineData(SyncBehaviour.Sync), InlineData(SyncBehaviour.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. The diff is a bit weird. This is the pre-existing test; it has no modifications other than adding the The above test is the new one, it is a copy-paste with tweaks to handle the exception on cancel. |
||
public async Task ShouldBeCancellable_DoNotThrowOnCancellation(SyncBehaviour behaviour) | ||
{ | ||
using var tempScript = TempScript.Create( | ||
cmd: """ | ||
@echo off | ||
echo Enter Name: | ||
set /p name= | ||
echo Hello '%name%' | ||
""", | ||
sh: """ | ||
echo "Enter Name:" | ||
read name | ||
echo "Hello '$name'" | ||
"""); | ||
|
||
var stdOut = new StringBuilder(); | ||
var stdErr = new StringBuilder(); | ||
|
||
// it's going to ask us for the name first, but we don't give it anything; the script should hang | ||
var stdIn = new TestInputSource(); | ||
|
||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken); | ||
|
||
var executor = new ShellCommand(tempScript.GetHostExecutable()) | ||
.WithOptions(ShellCommandOptions.DoNotThrowOnCancellation) | ||
.WithArguments(tempScript.GetCommandArgs()) | ||
.WithStdInSource(stdIn) | ||
.WithStdOutTarget(stdOut) | ||
.WithStdOutTarget(l => | ||
{ | ||
// when we receive the first prompt, cancel and kill the process | ||
if (l.Contains("Enter Name:")) cts.Cancel(); | ||
}) | ||
.WithStdErrTarget(stdErr); | ||
|
||
var result = behaviour == SyncBehaviour.Async | ||
? await executor.ExecuteAsync(cts.Token) | ||
: executor.Execute(cts.Token); | ||
|
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.
Using an enum means this will be a list of mutually exclusive boolean options (or not, if we added
[Flags]
). It's difficult to predict what options we might want to add in future, but this seems quite limited. I think a class would provide more flexibility.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.
I agree, but it's hard to predict the future. We might only have this one option for the next 7 years, in which case classes would be additional trouble for no benefit.
Given the limited use of Shellfish, if we find a future scenario where we need more kinds of options, I'd happily commit to a breaking API change at that point. (Note also: making it
[Flags]
is nonbreaking because we only have 0 and 1 right now)