diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c95e29..f9c2ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixes + +- Local emulator outages are now detected across Cosmos DB commands. Requests fail promptly with an error and return the shell to its disconnected state instead of leaving an unresponsive session labeled as connected. + ## 1.1.209-preview — 2026-08-26 ### New features diff --git a/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs index 137147a..717c784 100644 --- a/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs @@ -8,6 +8,7 @@ namespace CosmosShell.Tests.CommandTests; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.Lsp.Semantics; using Azure.Data.Cosmos.Shell.Parser; +using Azure.Data.Cosmos.Shell.States; using Azure.Data.Cosmos.Shell.Util; using Microsoft.Azure.Cosmos; using Spectre.Console; @@ -15,6 +16,40 @@ namespace CosmosShell.Tests.CommandTests; [Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)] public class ConnectCommandTests { + [Fact] + public void CreateClientOptions_Emulator_UsesShortRequestTimeout() + { + var options = ShellInterpreter.CreateClientOptions(ConnectionMode.Gateway, isEmulator: true); + + Assert.Equal(TimeSpan.FromSeconds(5), options.RequestTimeout); + } + + [Fact] + public void ConnectivityFailure_LocalEmulator_DisconnectsShell() + { + using var shell = ShellInterpreter.CreateInstance(); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:8081/;AccountKey=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;"); + shell.State = new ConnectedState(client); + + shell.DisconnectLocalEmulatorAfterConnectivityFailure(new HttpRequestException("Connection refused")); + + Assert.IsType(shell.State); + } + + [Fact] + public void CommandFailure_LocalEmulator_RemainsConnected() + { + using var shell = ShellInterpreter.CreateInstance(); + using var client = new CosmosClient( + "AccountEndpoint=https://localhost:8081/;AccountKey=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;"); + shell.State = new ConnectedState(client); + + shell.DisconnectLocalEmulatorAfterConnectivityFailure(new CommandException("ls", "invalid option")); + + Assert.IsType(shell.State); + } + [Fact] public async Task ConnectAsync_CanceledToken_CancelsConnectionAttempt() { diff --git a/CosmosDBShell.Tests/Shell/ExecuteCommandExceptionTests.cs b/CosmosDBShell.Tests/Shell/ExecuteCommandExceptionTests.cs index 426663a..5c93374 100644 --- a/CosmosDBShell.Tests/Shell/ExecuteCommandExceptionTests.cs +++ b/CosmosDBShell.Tests/Shell/ExecuteCommandExceptionTests.cs @@ -479,6 +479,26 @@ public void CommandException_ResponseStatusTimeout_PreservesRawMessageForVerbose Assert.Contains("CosmosDiagnostics", exception.ToString()); } + [Theory] + [InlineData(System.Net.HttpStatusCode.RequestTimeout)] + [InlineData(System.Net.HttpStatusCode.BadGateway)] + [InlineData(System.Net.HttpStatusCode.ServiceUnavailable)] + [InlineData(System.Net.HttpStatusCode.GatewayTimeout)] + public void CommandException_CosmosConnectivityStatus_IsConnectivityFailure(System.Net.HttpStatusCode statusCode) + { + var exception = new Microsoft.Azure.Cosmos.CosmosException("unavailable", statusCode, 0, "activity", 0); + + Assert.True(CommandException.IsConnectivityFailure(exception)); + } + + [Fact] + public void CommandException_NestedHttpRequestException_IsConnectivityFailure() + { + var exception = new InvalidOperationException("outer", new HttpRequestException("connection refused")); + + Assert.True(CommandException.IsConnectivityFailure(exception)); + } + [Fact] public async Task ExecuteCommandAsync_ShellException_PreservesExceptionInErrorState() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandException.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandException.cs index 55b2d6a..003e731 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandException.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandException.cs @@ -5,6 +5,7 @@ namespace Azure.Data.Cosmos.Shell.Core; using System.Net; +using System.Net.Sockets; using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Util; @@ -89,6 +90,30 @@ internal static CommandException FromResponseStatus(string command, HttpStatusCo return new CommandException(command, displayMessage); } + internal static bool IsConnectivityFailure(Exception exception) + { + if (exception is HttpRequestException or SocketException) + { + return true; + } + + if (exception is CosmosException cosmosException + && cosmosException.StatusCode is HttpStatusCode.RequestTimeout + or HttpStatusCode.BadGateway + or HttpStatusCode.ServiceUnavailable + or HttpStatusCode.GatewayTimeout) + { + return true; + } + + if (exception is OperationCanceledException && IsRequestTimeout(exception)) + { + return true; + } + + return exception.InnerException != null && IsConnectivityFailure(exception.InnerException); + } + private static string GetMessage(Exception exception) { return GetDisplayMessage(exception); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 4826518..b4d41e8 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -29,8 +29,6 @@ public partial class ShellInterpreter : IDisposable private const int MAXHISTORYITEMS = 60; - private const double TimeoutInSeconds = 10.0; - private const int OptionalArmDiscoveryTimeoutSeconds = 3; private const string EncodedHistoryLinePrefix = "CosmosDBShellHistoryV1:"; @@ -40,6 +38,8 @@ public partial class ShellInterpreter : IDisposable // user command that just happens to start with the prefix string. private const string EncodedHistoryLineMarker = "E:"; + private static readonly TimeSpan LocalEmulatorOperationTimeout = TimeSpan.FromSeconds(10); + private static CancellationTokenSource? currentTokenSource; private readonly string cfgPath; @@ -110,15 +110,6 @@ internal ShellInterpreter(string? configPath = null) /// public bool Echo { get; set; } = true; - internal static CancellationTokenSource TokenSource - { - get - { - currentTokenSource?.Dispose(); - return currentTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(TimeoutInSeconds)); - } - } - internal static CancellationTokenSource UserCancellationTokenSource { get @@ -425,6 +416,8 @@ public void CancelPrompt() public async Task ExecuteCommandAsync(string command, CancellationToken token) { using var activity = TracingBootstrap.StartCommandActivity("cosmosdbshell.command"); + var isLocalEmulatorOperation = this.State is ConnectedState connectedState + && ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(connectedState.Client?.Endpoint.ToString()); var state = new CommandState(); // Snapshot redirect state so a '>' / '2>' on this command does not leak into @@ -439,12 +432,17 @@ public async Task ExecuteCommandAsync(string command, Cancellation diagnostics?.LogCommand(command); CommandState? result = null; var wasCancelled = false; + using var operationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + if (isLocalEmulatorOperation) + { + operationTokenSource.CancelAfter(LocalEmulatorOperationTimeout); + } try { try { - state = await this.RunCommandAsync(state, command, token); + state = await this.RunCommandAsync(state, command, operationTokenSource.Token); } catch (OperationCanceledException) when (token.IsCancellationRequested) { @@ -452,16 +450,28 @@ public async Task ExecuteCommandAsync(string command, Cancellation result = new CommandState(); return result; } + catch (OperationCanceledException e) when (isLocalEmulatorOperation && operationTokenSource.IsCancellationRequested) + { + var shellException = new ShellException( + CommandException.GetDisplayMessage(System.Net.HttpStatusCode.RequestTimeout, e.Message), + e); + this.ReportExecutionError(shellException, command); + this.Disconnect(); + result = new ErrorCommandState(shellException); + return result; + } catch (TaskCanceledException e) { var shellException = new ShellException(CommandException.GetDisplayMessage(e), e); this.ReportExecutionError(shellException, command); + this.DisconnectLocalEmulatorAfterConnectivityFailure(e); result = new ErrorCommandState(shellException); return result; } catch (Exception e) { this.ReportExecutionError(e, command); + this.DisconnectLocalEmulatorAfterConnectivityFailure(e); var inner = e is PositionalException pe ? (pe.InnerException ?? pe) : e; result = new ErrorCommandState(inner); return result; @@ -769,7 +779,7 @@ internal async Task RunAsync() this.history.Remove(command); this.history.Add(command); this.SaveHistory(); - CancellationToken token = TokenSource.Token; + CancellationToken token = UserCancellationTokenSource.Token; await this.ExecuteCommandAsync(command, token); } } @@ -933,13 +943,19 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu { WriteLine(MessageService.GetString("shell-connect-key-auth")); var keyMode = mode ?? (isEmulator ? ConnectionMode.Gateway : ConnectionMode.Direct); - var keyOptions = CreateClientOptions(keyMode); + var keyOptions = CreateClientOptions(keyMode, isEmulator); client = new CosmosClient(connectionString, keyOptions); AccountProperties keyProps; + using var operationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(token); + if (isEmulator) + { + operationTokenSource.CancelAfter(LocalEmulatorOperationTimeout); + } + try { - keyProps = await ReadAccountAsync(client, token); + keyProps = await ReadAccountAsync(client, operationTokenSource.Token); } catch (OperationCanceledException) when (token.IsCancellationRequested) { @@ -1525,6 +1541,16 @@ internal void Disconnect() this.State = new DisconnectedState(); } + internal void DisconnectLocalEmulatorAfterConnectivityFailure(Exception exception) + { + if (this.State is ConnectedState connectedState + && ParsedDocDBConnectionString.IsLocalEmulatorEndpoint(connectedState.Client.Endpoint.ToString()) + && CommandException.IsConnectivityFailure(exception)) + { + this.Disconnect(); + } + } + internal void PrintCommand(string cmdString) { // Print the shell prompt similar to how it appears when typing command @@ -1768,7 +1794,7 @@ protected virtual void Dispose(bool disposing) return Console.ReadLine(); } - private static CosmosClientOptions CreateClientOptions(ConnectionMode requestedMode) + internal static CosmosClientOptions CreateClientOptions(ConnectionMode requestedMode, bool isEmulator = false) { var options = new CosmosClientOptions { @@ -1784,6 +1810,11 @@ private static CosmosClientOptions CreateClientOptions(ConnectionMode requestedM }, }; + if (isEmulator) + { + options.RequestTimeout = TimeSpan.FromSeconds(5); + } + return options; }