Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions CosmosDBShell.Tests/CommandTests/ConnectCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,48 @@ 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;

[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<DisconnectedState>(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<ConnectedState>(shell.State);
}

[Fact]
public async Task ConnectAsync_CanceledToken_CancelsConnectionAttempt()
{
Expand Down
20 changes: 20 additions & 0 deletions CosmosDBShell.Tests/Shell/ExecuteCommandExceptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
25 changes: 25 additions & 0 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CommandException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
63 changes: 47 additions & 16 deletions CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:";
Expand All @@ -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;
Expand Down Expand Up @@ -110,15 +110,6 @@ internal ShellInterpreter(string? configPath = null)
/// </summary>
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
Expand Down Expand Up @@ -425,6 +416,8 @@ public void CancelPrompt()
public async Task<CommandState> 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();
Comment on lines 418 to 421

// Snapshot redirect state so a '>' / '2>' on this command does not leak into
Expand All @@ -439,29 +432,46 @@ public async Task<CommandState> 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)
{
wasCancelled = true;
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;
Expand Down Expand Up @@ -769,7 +779,7 @@ internal async Task<int> RunAsync()
this.history.Remove(command);
this.history.Add(command);
this.SaveHistory();
CancellationToken token = TokenSource.Token;
CancellationToken token = UserCancellationTokenSource.Token;
await this.ExecuteCommandAsync(command, token);
}
}
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand All @@ -1784,6 +1810,11 @@ private static CosmosClientOptions CreateClientOptions(ConnectionMode requestedM
},
};

if (isEmulator)
{
options.RequestTimeout = TimeSpan.FromSeconds(5);
}

return options;
}

Expand Down