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
45 changes: 30 additions & 15 deletions packages/core/src/awsService/sagemaker/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
RemoteAccess,
RemoteAccessRequiredMessage,
SpaceStatus,
SshConfigError,
} from './constants'
import { SagemakerUnifiedStudioSpaceNode } from '../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode'

Expand Down Expand Up @@ -210,19 +211,26 @@ export async function openRemoteConnect(
return
}

const spaceName = node.spaceApp.SpaceName!
await tryRefreshNode(node)
try {
const spaceName = node.spaceApp.SpaceName!
await tryRefreshNode(node)

const remoteAccess = node.spaceApp.SpaceSettingsSummary?.RemoteAccess
const nodeStatus = node.getStatus()
const remoteAccess = node.spaceApp.SpaceSettingsSummary?.RemoteAccess
const nodeStatus = node.getStatus()

// Route to appropriate handler based on space state
if (nodeStatus === SpaceStatus.RUNNING && remoteAccess !== RemoteAccess.ENABLED) {
return handleRunningSpaceWithDisabledAccess(node, ctx, spaceName, sageMakerClient)
} else if (nodeStatus === SpaceStatus.STOPPED) {
return handleStoppedSpace(node, ctx, spaceName, sageMakerClient)
} else if (nodeStatus === SpaceStatus.RUNNING) {
return handleRunningSpaceWithEnabledAccess(node, ctx, spaceName)
// Route to appropriate handler based on space state
if (nodeStatus === SpaceStatus.RUNNING && remoteAccess !== RemoteAccess.ENABLED) {
return await handleRunningSpaceWithDisabledAccess(node, ctx, spaceName, sageMakerClient)
} else if (nodeStatus === SpaceStatus.STOPPED) {
return await handleStoppedSpace(node, ctx, spaceName, sageMakerClient)
} else if (nodeStatus === SpaceStatus.RUNNING) {
return await handleRunningSpaceWithEnabledAccess(node, ctx, spaceName)
}
} catch (err: any) {
// Suppress errors that were already shown to user
if (shouldSuppressError(err)) {
return
}
}
}

Expand Down Expand Up @@ -346,8 +354,8 @@ async function handleRunningSpaceWithDisabledAccess(
)
await tryRemoteConnection(node, ctx, progress)
} catch (err: any) {
// Handle user declining instance type upgrade
if (err.code === InstanceTypeError) {
// Suppress errors that were already shown to user
if (shouldSuppressError(err)) {
return
}
throw new ToolkitError(`Remote connection failed: ${err.message}`, {
Expand Down Expand Up @@ -393,8 +401,8 @@ async function handleStoppedSpace(
}
)
} catch (err: any) {
// Handle user declining instance type upgrade
if (err.code === InstanceTypeError) {
// Suppress errors that were already shown to user
if (shouldSuppressError(err)) {
return
}
throw new ToolkitError(`Remote connection failed: ${(err as Error).message}`, {
Expand Down Expand Up @@ -425,3 +433,10 @@ async function handleRunningSpaceWithEnabledAccess(
}
)
}

/**
* Helper function to determine if an error should be suppressed (already shown to user)
*/
function shouldSuppressError(err: any): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I read this method name, I generate many questions in my head.

  • Why are we suppressing errors?
  • In which context is it appropriate to suppress errors?
  • When is it appropriate to update this method? What all is referencing this method?
  • How does this method know which errors should be suppressed?
  • How do we know this error was already shown to the user? (A question I would have anyway)

Part of the problem is that this method is making a decision (made clear by the word should), which realistically can't be made without context that isn't provided to the method. An example of such context is the comment which says "Suppress errors that were already shown to user". That is something this method cannot possibly know.

To improve, we can simply rename the method (e.g., isUserConfigurationError, isUserCancelledError), or use error metadata (isUserCancelledError() is an existing helper method in the repository for this use case).

return err.code === SshConfigError || err.code === InstanceTypeError
}
5 changes: 5 additions & 0 deletions packages/core/src/awsService/sagemaker/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const ConnectFromRemoteWorkspaceMessage =
'Unable to establish new remote connection. Your last active VS Code window is connected to a remote workspace. To open a new SageMaker Studio connection, select your local VS Code window and try again.'

export const InstanceTypeError = 'InstanceTypeError'
export const SshConfigError = 'SshConfigError'

export const InstanceTypeMinimum = 'ml.t3.large'

Expand Down Expand Up @@ -46,6 +47,10 @@ export const InstanceTypeNotSelectedMessage = (spaceName: string) => {
export const RemoteAccessRequiredMessage =
'This space requires remote access to be enabled.\nWould you like to restart the space and connect?\nAny unsaved work will be lost.'

export const SshConfigErrorMessage = () => {
return `Unable to connect. ~/.ssh/config contains invalid SSH configuration. Fix the errors to continue.`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we write the "~" character in other error messages? Windows doesn't use it so we shouldn't explicitly write this. We can possibly show it if we have confirmed it is applicable to the user's environment. Alternatively, we display the exact path, though it may be a bit too long.

}

export const SmusDeeplinkSessionExpiredError = {
title: 'Session Disconnected',
message:
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/awsService/sagemaker/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ToolkitError } from '../../shared/errors'
import { SagemakerSpaceNode } from './explorer/sagemakerSpaceNode'
import { sleep } from '../../shared/utilities/timeoutUtils'
import { SagemakerUnifiedStudioSpaceNode } from '../../sagemakerunifiedstudio/explorer/nodes/sageMakerUnifiedStudioSpaceNode'
import { SshConfigError, SshConfigErrorMessage } from './constants'

const logger = getLogger('sagemaker')

Expand Down Expand Up @@ -96,6 +97,23 @@ export async function prepareDevEnvConnection(
if (config.isErr()) {
const err = config.err()
logger.error(`sagemaker: failed to add ssh config section: ${err.message}`)

if (err instanceof ToolkitError && err.code === 'SshCheckFailed') {
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the getSshConfigPath() helper method?


// Extracting line number from SSH error message is the best-effort.
// SSH error formats are not standardized and may vary across implementations.
await vscode.window
.showErrorMessage(SshConfigErrorMessage(), { modal: true, detail: err.message }, 'Open SSH Config')
.then((resp) => {
if (resp === 'Open SSH Config') {
void vscode.window.showTextDocument(vscode.Uri.file(sshConfigPath))
}
})

throw new ToolkitError('SSH configuration error', { code: SshConfigError, cancelled: true })
}

throw err
}

Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/shared/sshConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,18 @@ export class SshConfig {
protected async matchSshSection() {
const result = await this.checkSshOnHost()
if (result.exitCode !== 0) {
return Result.err(result.error ?? new Error(`ssh check against host failed: ${result.exitCode}`))
// Format stderr error message
let errorMessage = result.stderr?.trim() || `ssh check against host failed: ${result.exitCode}`
const sshConfigPath = getSshConfigPath()
errorMessage = errorMessage.replace(new RegExp(`${sshConfigPath}:\\s*`, 'g'), '').trim()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could benefit from a comment explaining the purpose.


if (result.error) {
// System level error
return Result.err(ToolkitError.chain(result.error, errorMessage))
}

// SSH ran but returned error exit code
return Result.err(new ToolkitError(errorMessage, { code: 'SshCheckFailed' }))
}
const matches = result.stdout.match(this.proxyCommandRegExp)
return Result.ok(matches?.[0])
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/test/shared/sshConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class MockSshConfig extends SshConfig {
// State variables to track logic flow.
public testIsWin: boolean = false
public configSection: string = ''
public exitCodeOverride: number = 0

public async getProxyCommandWrapper(command: string): Promise<Result<string, ToolkitError>> {
return await this.getProxyCommand(command)
Expand All @@ -51,7 +52,7 @@ class MockSshConfig extends SshConfig {

protected override async checkSshOnHost(): Promise<ChildProcessResult> {
return {
exitCode: 0,
exitCode: this.exitCodeOverride,
error: undefined,
stdout: this.configSection,
stderr: '',
Expand Down Expand Up @@ -95,6 +96,10 @@ describe('VscodeRemoteSshConfig', async function () {
})

describe('matchSshSection', async function () {
beforeEach(function () {
config.exitCodeOverride = 0
})

it('returns ok with match when proxycommand is present', async function () {
const testSection = `proxycommandfdsafdsafd${testProxyCommand}sa342432`
const result = await config.testMatchSshSection(testSection)
Expand All @@ -110,6 +115,16 @@ describe('VscodeRemoteSshConfig', async function () {
const match = result.unwrap()
assert.strictEqual(match, undefined)
})

it('returns error when ssh check fails with non-zero exit code', async function () {
config.exitCodeOverride = 255
const testSection = `some config`
const result = await config.testMatchSshSection(testSection)
assert.ok(result.isErr())
const error = result.err()
assert.ok(error.message.includes('ssh check against host failed'))
assert.ok(error.message.includes('255'))
})
})

describe('verifySSHHost', async function () {
Expand All @@ -122,6 +137,7 @@ describe('VscodeRemoteSshConfig', async function () {
})

beforeEach(function () {
config.exitCodeOverride = 0
promptUserToConfigureSshConfigStub.resetHistory()
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Bug Fix",
"description": "SageMaker: Enhanced SSH configuration error handling to show user-friendly modal dialogs with line numbers and an \"Open SSH Config\" button for quick fixes."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are some suggestions regarding change log entries:

  • Be objective, avoid wording like "user-friendly" and "quick"
  • Do not end in a period as they are list items (which generally don't use punctuation)
  • Avoid the word "Enhanced", prefer "Improved" instead (appears 10+ times in existing change log)
  • Use AI to help rephrase in the existing style (have it read the CHANGELOG.md file)

Here's an alternative wording (AI-assisted):

SageMaker: SSH configuration errors now display line numbers and include an "Open SSH Config" button

}
Loading