Skip to content

Commit 79a1735

Browse files
authored
fix(settings): endpoints is incorrectly trapped by DevSettings (#3305)
## Problem Calling `DevSettings.instance.get('endpoints', {})` always treats endpoints as being an enabled setting. This would enable 'dev mode' despite no explicit setting being present. ## Solution Remove the fragile reference comparison for trapping settings. Explicitly check for set values instead. Other changes: * Small refactor on the status bar item to make it more robust * Dev settings that have been unset can now be detected * Move throttle so it can be re-used; add tests (TODO: remove lodash) * Had to bump @sinonjs/faketimers to make one test work w/ fake timers. It'd work without fakes but might be flaky.
1 parent d6de7ad commit 79a1735

File tree

12 files changed

+251
-90
lines changed

12 files changed

+251
-90
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Bug Fix",
3+
"description": "The AWS status bar item is colored despite there not being anything that needs user attention"
4+
}

package-lock.json

Lines changed: 36 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3441,7 +3441,7 @@
34413441
"devDependencies": {
34423442
"@aws-toolkits/telemetry": "^1.0.120",
34433443
"@cspotcode/source-map-support": "^0.8.1",
3444-
"@sinonjs/fake-timers": "^8.1.0",
3444+
"@sinonjs/fake-timers": "^10.0.2",
34453445
"@types/adm-zip": "^0.4.34",
34463446
"@types/async-lock": "^1.4.0",
34473447
"@types/bytes": "^3.1.0",
@@ -3457,7 +3457,7 @@
34573457
"@types/readline-sync": "^1.4.3",
34583458
"@types/semver": "^7.3.6",
34593459
"@types/sinon": "^10.0.5",
3460-
"@types/sinonjs__fake-timers": "^8.1.1",
3460+
"@types/sinonjs__fake-timers": "^8.1.2",
34613461
"@types/tcp-port-used": "^1.0.1",
34623462
"@types/uuid": "^8.3.3",
34633463
"@types/vscode": "1.50.0",

src/awsexplorer/localExplorer.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import * as vscode from 'vscode'
77
import { ResourceTreeDataProvider, TreeNode } from '../shared/treeview/resourceTreeDataProvider'
88
import { isCloud9 } from '../shared/extensionUtilities'
9+
import { throttle } from '../shared/utilities/functionUtils'
910

1011
export interface RootNode<T = unknown> extends TreeNode<T> {
1112
/**
@@ -21,26 +22,6 @@ export interface RootNode<T = unknown> extends TreeNode<T> {
2122
canShow?(): Promise<boolean> | boolean
2223
}
2324

24-
function throttle<T>(cb: () => T | Promise<T>, delay: number): () => Promise<T> {
25-
let timer: NodeJS.Timeout | undefined
26-
let promise: Promise<T> | undefined
27-
28-
return () => {
29-
timer?.refresh()
30-
31-
return (promise ??= new Promise<T>((resolve, reject) => {
32-
timer = setTimeout(async () => {
33-
timer = promise = undefined
34-
try {
35-
resolve(await cb())
36-
} catch (err) {
37-
reject(err)
38-
}
39-
}, delay)
40-
}))
41-
}
42-
}
43-
4425
/**
4526
* The 'local' explorer is represented as 'Developer Tools' in the UI. We use a different name within
4627
* source code to differentiate between _Toolkit developers_ and _Toolkit users_.

src/credentials/statusBarItem.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { DevSettings } from '../shared/settings'
1313
import { Auth, login } from './auth'
1414
import { getAllConnectionsInUse, onDidChangeConnections } from './secondaryAuth'
1515
import { codicon, getIcon } from '../shared/icons'
16-
import { shared } from '../shared/utilities/functionUtils'
16+
import { throttle } from '../shared/utilities/functionUtils'
1717

1818
const statusbarPriority = 1
1919

@@ -26,22 +26,19 @@ export async function initializeAwsCredentialsStatusBarItem(
2626
statusBarItem.command = login.build().asCommand({ title: 'Login' })
2727
statusBarItem.show()
2828

29-
const update = shared(async () => {
30-
updateItem(statusBarItem)
31-
handleDevSettings(devSettings, statusBarItem)
32-
})
29+
const update = throttle(() => updateItem(statusBarItem, devSettings))
3330

3431
update()
3532
context.subscriptions.push(
3633
statusBarItem,
3734
onDidChangeConnections(() => update()),
3835
Auth.instance.onDidChangeActiveConnection(() => update()),
3936
Auth.instance.onDidChangeConnectionState(() => update()),
40-
devSettings.onDidChangeActiveSettings(() => handleDevSettings(devSettings, statusBarItem))
37+
devSettings.onDidChangeActiveSettings(() => update())
4138
)
4239
}
4340

44-
function handleDevSettings(devSettings: DevSettings, statusBarItem: vscode.StatusBarItem) {
41+
function handleDevSettings(statusBarItem: vscode.StatusBarItem, devSettings: DevSettings) {
4542
const developerMode = Object.keys(devSettings.activeSettings)
4643

4744
if (developerMode.length > 0) {
@@ -52,7 +49,7 @@ function handleDevSettings(devSettings: DevSettings, statusBarItem: vscode.Statu
5249
}
5350
}
5451

55-
function updateItem(statusBarItem: vscode.StatusBarItem): void {
52+
function updateItem(statusBarItem: vscode.StatusBarItem, devSettings: DevSettings): void {
5653
const company = getIdeProperties().company
5754
const connections = getAllConnectionsInUse(Auth.instance)
5855
const connectedTooltip = localize(
@@ -88,4 +85,7 @@ function updateItem(statusBarItem: vscode.StatusBarItem): void {
8885
? new vscode.ThemeColor('statusBarItem.errorBackground')
8986
: undefined
9087
;(statusBarItem as any).backgroundColor = color
88+
89+
// Do this last to override the normal behavior
90+
handleDevSettings(statusBarItem, devSettings)
9191
}

src/shared/settings.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ type Workspace = Pick<typeof vscode.workspace, 'getConfiguration' | 'onDidChange
2525
*/
2626
export class Settings {
2727
public constructor(
28-
private readonly target = vscode.ConfigurationTarget.Global,
28+
private readonly updateTarget = vscode.ConfigurationTarget.Global,
2929
private readonly scope: vscode.ConfigurationScope | undefined = undefined,
3030
private readonly workspace: Workspace = vscode.workspace
3131
) {}
@@ -59,7 +59,7 @@ export class Settings {
5959
*/
6060
public async update(key: string, value: unknown): Promise<boolean> {
6161
try {
62-
await this.getConfig().update(key, value, this.target)
62+
await this.getConfig().update(key, value, this.updateTarget)
6363

6464
return true
6565
} catch (error) {
@@ -69,6 +69,20 @@ export class Settings {
6969
}
7070
}
7171

72+
/**
73+
* Checks if the key has been set in any non-language scope.
74+
*/
75+
public isSet(key: string, section?: string): boolean {
76+
const config = this.getConfig(section)
77+
const info = config.inspect(key)
78+
79+
return (
80+
info?.globalValue !== undefined ||
81+
info?.workspaceValue !== undefined ||
82+
info?.workspaceFolderValue !== undefined
83+
)
84+
}
85+
7286
/**
7387
* Returns a scoped "slice" (or "view") of the settings configuration.
7488
*
@@ -84,13 +98,11 @@ export class Settings {
8498
* ```
8599
*/
86100
public getSection(section: string): ResetableMemento {
87-
const parts = section.split('.')
88-
const targetKey = parts.pop() ?? section
89-
const parentSection = parts.join('.')
101+
const [targetKey, parentSection] = splitKey(section)
90102

91103
return {
92104
get: (key, defaultValue?) => this.getConfig(section).get(key, defaultValue),
93-
reset: async () => this.getConfig().update(section, undefined, this.target),
105+
reset: async () => this.getConfig().update(section, undefined, this.updateTarget),
94106
update: async (key, value) => {
95107
// VS Code's settings API can read nested props but not write to them.
96108
// We need to write to the parent if we cannot write to the child.
@@ -99,13 +111,13 @@ export class Settings {
99111
// handle this asymmetry with try/catch
100112

101113
try {
102-
return await this.getConfig(section).update(key, value, this.target)
114+
return await this.getConfig(section).update(key, value, this.updateTarget)
103115
} catch (error) {
104116
const parent = this.getConfig(parentSection)
105117
const val = parent.get(targetKey)
106118

107119
if (typeof val === 'object') {
108-
return parent.update(targetKey, { ...val, [key]: value }, this.target)
120+
return parent.update(targetKey, { ...val, [key]: value }, this.updateTarget)
109121
}
110122

111123
throw error
@@ -161,6 +173,24 @@ export class Settings {
161173
}
162174
}
163175

176+
/**
177+
* Splits a key into 'leaf' and 'section' components.
178+
*
179+
* The leaf is assumed to be the last dot-separated component. Example:
180+
*
181+
* ```ts
182+
* const [leaf, section] = this.split('aws.cloudWatchLogs.limit')
183+
* console.log(leaf, section) // aws.cloudWatchLogs limit
184+
* ```
185+
*/
186+
function splitKey(key: string): [leaf: string, section: string] {
187+
const parts = key.split('.')
188+
const leaf = parts.pop() ?? key
189+
const section = parts.join('.')
190+
191+
return [leaf, section]
192+
}
193+
164194
// Keeping a separate function without a return type allows us to infer protected methods
165195
// TODO(sijaden): we can make this better in TS 4.7
166196
function createSettingsClass<T extends TypeDescriptor>(section: string, descriptor: T) {
@@ -238,6 +268,10 @@ function createSettingsClass<T extends TypeDescriptor>(section: string, descript
238268
return vscode.Disposable.from(this.getChangedEmitter(), ...this.disposables).dispose()
239269
}
240270

271+
protected isSet(key: keyof Inner & string) {
272+
return this.settings.isSet(key, section)
273+
}
274+
241275
protected getOrThrow<K extends keyof Inner>(key: K & string, defaultValue?: Inner[K]) {
242276
const value = this.config.get(key, defaultValue)
243277

@@ -594,12 +628,15 @@ export class DevSettings extends Settings.define('aws.dev', devSettings) {
594628
}
595629

596630
public override get<K extends AwsDevSetting>(key: K, defaultValue: ResolvedDevSettings[K]) {
597-
const result = super.get(key, defaultValue)
631+
if (!this.isSet(key)) {
632+
this.unset(key)
598633

599-
if (result !== defaultValue) {
600-
this.trap(key, result)
634+
return defaultValue
601635
}
602636

637+
const result = super.get(key, defaultValue)
638+
this.trap(key, result)
639+
603640
return result
604641
}
605642

@@ -610,6 +647,13 @@ export class DevSettings extends Settings.define('aws.dev', devSettings) {
610647
}
611648
}
612649

650+
private unset(key: AwsDevSetting) {
651+
if (key in this.trappedSettings) {
652+
delete this.trappedSettings[key]
653+
this.onDidChangeActiveSettingsEmitter.fire()
654+
}
655+
}
656+
613657
static #instance: DevSettings
614658

615659
public static get instance() {

src/shared/utilities/functionUtils.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import globals from '../extensionGlobals'
7+
68
/**
79
* Creates a function that always returns a 'shared' Promise.
810
*
@@ -54,3 +56,32 @@ export function memoize<T, U extends any[]>(fn: (...args: U) => T): (...args: U)
5456

5557
return (...args) => (cache[args.map(String).join(':')] ??= fn(...args))
5658
}
59+
60+
/**
61+
* Prevents a function from executing until {@link delay} milliseconds have passed
62+
* since the last invocation. Omitting {@link delay} will throttle the function for
63+
* a single event loop.
64+
*
65+
* Multiple calls made during the throttle window will receive references to the
66+
* same Promise similar to {@link shared}. The window will also be 'rolled', delaying
67+
* the execution by another {@link delay} milliseconds.
68+
*/
69+
export function throttle<T>(cb: () => T | Promise<T>, delay: number = 0): () => Promise<T> {
70+
let timer: NodeJS.Timeout | undefined
71+
let promise: Promise<T> | undefined
72+
73+
return () => {
74+
timer?.refresh()
75+
76+
return (promise ??= new Promise<T>((resolve, reject) => {
77+
timer = globals.clock.setTimeout(async () => {
78+
timer = promise = undefined
79+
try {
80+
resolve(await cb())
81+
} catch (err) {
82+
reject(err)
83+
}
84+
}, delay)
85+
}))
86+
}
87+
}

src/shared/utilities/timeoutUtils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,6 @@ export async function waitTimeout<T, R = void, B extends boolean = true>(
275275
*
276276
* Attempts to use the extension-scoped `setTimeout` if it exists, otherwise will fallback to the global scheduler.
277277
*/
278-
279278
export function sleep(duration: number = 0): Promise<void> {
280279
const schedule = globals?.clock?.setTimeout ?? setTimeout
281280
return new Promise(r => schedule(r, Math.max(duration, 0)))

0 commit comments

Comments
 (0)