Skip to content

Commit 3eb168d

Browse files
fix: FileNotFound error during SSO token refresh (#5392)
* refactor: increase scrubNames() file ext limit Problem: scrubNames() removes PII from a file path. There is also a limit to the file extension length of 4 characters. If the length is greater, the file ext is fully excluded. We have some scenarios in telemetry where the file has no file ext but we want to see it. A guess is that there are some cases where the file ext is longer than 4 chars. Solution: Show the file ext regardless of its length. Signed-off-by: Nikolas Komonen <[email protected]> * bug: rename() fails causing FileNotFoundError Problem: In our aws_refreshCredentials telemetry we saw a spike in FileNotFound errors when writing the refreshed token to our filesystem. This is caused by something in the vscode.workspace.fs.rename() implementation that is not obvious and does not seem like an obvious error on our end. Most of the time things work, so most users are not running in to this problem. Originally, to fix some errors happening during writing to the filesystem we created an "atomic" write, but there are edge cases where this did not work. So there are different implementations of writing a file that we used to rememdy this. Solution: Try the next fallback methods for saving the file to the file system when one fails. See the comment in the code for the explanation of the implementation. We also report telemetry on the specific failed cases to hopefully get a better understanding of the specific failures. We can use this Signed-off-by: Nikolas Komonen <[email protected]> * changelog items Signed-off-by: Nikolas Komonen <[email protected]> * add tests + logging Signed-off-by: Nikolas Komonen <[email protected]> --------- Signed-off-by: Nikolas Komonen <[email protected]>
1 parent 9b8f4d0 commit 3eb168d

File tree

6 files changed

+116
-14
lines changed

6 files changed

+116
-14
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": "FileNotFound error causing early SSO expiration"
4+
}

packages/core/src/shared/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ export function getTelemetryResult(error: unknown | undefined): Result {
345345
*/
346346
export function scrubNames(s: string, username?: string) {
347347
let r = ''
348-
const fileExtRe = /\.[^.\/]{1,4}$/
348+
const fileExtRe = /\.[^.\/]+$/
349349
const slashdot = /^[~.]*[\/\\]*/
350350

351351
/** Allowlisted filepath segments. */

packages/core/src/shared/fs/fs.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
5-
import * as vscode from 'vscode'
5+
import vscode from 'vscode'
66
import os from 'os'
77
import { promises as nodefs, constants as nodeConstants, WriteFileOptions } from 'fs'
88
import { isCloud9 } from '../extensionUtilities'
@@ -11,6 +11,7 @@ import {
1111
PermissionsError,
1212
PermissionsTriplet,
1313
ToolkitError,
14+
getTelemetryReasonDesc,
1415
isFileNotFoundError,
1516
isPermissionsError,
1617
scrubNames,
@@ -21,6 +22,7 @@ import { resolvePath } from '../utilities/pathUtils'
2122
import crypto from 'crypto'
2223
import { waitUntil } from '../utilities/timeoutUtils'
2324
import { telemetry } from '../telemetry/telemetry'
25+
import { getLogger } from '../logger/logger'
2426

2527
const vfs = vscode.workspace.fs
2628
type Uri = vscode.Uri
@@ -236,13 +238,55 @@ export class FileSystem {
236238
await fs.mkdir(_path.dirname(uri.fsPath))
237239

238240
if (opts?.atomic) {
241+
// Background: As part of an "atomic write" we write to a temp file, then rename
242+
// to the target file. The problem was that each `rename()` implementation doesn't always
243+
// work. The solution is to try each implementation and then fallback to a regular write if none of them work.
244+
// 1. Atomic write with VSC rename(), but may throw `FileNotFound` errors
245+
// - Looks to be this error from what we see in telemetry: https://github.com/microsoft/vscode/blob/09d5f4efc5089ce2fc5c8f6aeb51d728d7f4e758/src/vs/platform/files/common/fileService.ts#L1029-L1030
246+
// 2. Atomic write with Node rename(), but may throw `EPERM` errors on Windows
247+
// - This is explained in https://github.com/aws/aws-toolkit-vscode/pull/5335
248+
// - Step 1 is supposed to address the EPERM issue
249+
// 3. Finally, do a regular file write, but may result in invalid file content
250+
//
251+
// For telemetry, we will only report failures as to not overload with succeed events.
239252
const tempFile = this.#toUri(`${uri.fsPath}.${crypto.randomBytes(8).toString('hex')}.tmp`)
240-
await write(tempFile)
241-
await fs.rename(tempFile, uri)
242-
return
243-
} else {
244-
await write(uri)
253+
try {
254+
await write(tempFile)
255+
await fs.rename(tempFile, uri)
256+
return
257+
} catch (e) {
258+
telemetry.ide_fileSystem.emit({
259+
action: 'writeFile',
260+
result: 'Failed',
261+
reason: 'writeFileAtomicVscRename',
262+
reasonDesc: getTelemetryReasonDesc(e),
263+
})
264+
getLogger().warn(`writeFile atomic VSC failed for, ${uri.fsPath}, with %O`, e)
265+
// Atomic write with VSC rename() failed, so try with Node rename()
266+
try {
267+
await write(tempFile)
268+
await nodefs.rename(tempFile.fsPath, uri.fsPath)
269+
return
270+
} catch (e) {
271+
telemetry.ide_fileSystem.emit({
272+
action: 'writeFile',
273+
result: 'Failed',
274+
reason: 'writeFileAtomicNodeRename',
275+
reasonDesc: getTelemetryReasonDesc(e),
276+
})
277+
getLogger().warn(`writeFile atomic Node failed for, ${uri.fsPath}, with %O`, e)
278+
// The atomic rename techniques were not successful, so we will
279+
// just resort to regular a non-atomic write
280+
}
281+
} finally {
282+
// clean up temp file since it possibly remains
283+
if (await fs.exists(tempFile)) {
284+
await fs.delete(tempFile)
285+
}
286+
}
245287
}
288+
289+
await write(uri)
246290
}
247291

248292
async rename(oldPath: vscode.Uri | string, newPath: vscode.Uri | string) {

packages/core/src/test/shared/errors.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -518,12 +518,17 @@ describe('util', function () {
518518
scrubNames('user: jdoe123 file: C:/Users/user1/.aws/sso/cache/abc123.json (regex: /foo/)', fakeUser),
519519
'user: x file: C:/Users/x/.aws/sso/cache/x.json (regex: /x/)'
520520
)
521-
assert.deepStrictEqual(scrubNames('/Users/user1/foo.jso (?)', fakeUser), '/Users/x/x.jso (?)')
522-
assert.deepStrictEqual(scrubNames('/Users/user1/foo.js (?)', fakeUser), '/Users/x/x.js (?)')
523-
assert.deepStrictEqual(scrubNames('/Users/user1/foo.longextension (?)', fakeUser), '/Users/x/x (?)')
524-
assert.deepStrictEqual(scrubNames('/Users/user1/foo.ext1.ext2.ext3', fakeUser), '/Users/x/x.ext3')
525-
assert.deepStrictEqual(scrubNames('/Users/user1/extMaxLength.1234', fakeUser), '/Users/x/x.1234')
526-
assert.deepStrictEqual(scrubNames('/Users/user1/extExceedsMaxLength.12345', fakeUser), '/Users/x/x')
521+
assert.deepStrictEqual(scrubNames('/Users/user1/foo.jso', fakeUser), '/Users/x/x.jso')
522+
assert.deepStrictEqual(scrubNames('/Users/user1/foo.js', fakeUser), '/Users/x/x.js')
523+
assert.deepStrictEqual(scrubNames('/Users/user1/noFileExtension', fakeUser), '/Users/x/x')
524+
assert.deepStrictEqual(scrubNames('/Users/user1/minExtLength.a', fakeUser), '/Users/x/x.a')
525+
assert.deepStrictEqual(scrubNames('/Users/user1/extIsNum.123456', fakeUser), '/Users/x/x.123456')
526+
assert.deepStrictEqual(
527+
scrubNames('/Users/user1/foo.looooooooongextension', fakeUser),
528+
'/Users/x/x.looooooooongextension'
529+
)
530+
assert.deepStrictEqual(scrubNames('/Users/user1/multipleExts.ext1.ext2.ext3', fakeUser), '/Users/x/x.ext3')
531+
527532
assert.deepStrictEqual(scrubNames('c:\\fooß\\bar\\baz.txt', fakeUser), 'c:/xß/x/x.txt')
528533
assert.deepStrictEqual(
529534
scrubNames('uhh c:\\path with\\ spaces \\baz.. hmm...', fakeUser),

packages/core/src/test/shared/fs/fs.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
*/
55

66
import assert from 'assert'
7-
import * as vscode from 'vscode'
7+
import vscode from 'vscode'
88
import * as path from 'path'
99
import * as utils from 'util'
1010
import { existsSync, mkdirSync, promises as nodefs, readFileSync, rmSync } from 'fs'
11+
import nodeFs from 'fs'
1112
import { FakeExtensionContext } from '../../fakeExtensionContext'
1213
import fs, { FileSystem } from '../../../shared/fs/fs'
1314
import * as os from 'os'
@@ -19,6 +20,7 @@ import { EnvironmentVariables } from '../../../shared/environmentVariables'
1920
import * as testutil from '../../testUtil'
2021
import globals from '../../../shared/extensionGlobals'
2122
import { driveLetterRegex } from '../../../shared/utilities/pathUtils'
23+
import { IdeFileSystem } from '../../../shared/telemetry/telemetry.gen'
2224

2325
describe('FileSystem', function () {
2426
let fakeContext: vscode.ExtensionContext
@@ -93,6 +95,49 @@ describe('FileSystem', function () {
9395
assert.strictEqual(readFileSync(filePath, 'utf-8'), 'MyContent')
9496
})
9597

98+
// We try multiple methods to do an atomic write, but if one fails we want to fallback
99+
// to the next method. The following are the different combinations of this when a method throws.
100+
const throwCombinations = [
101+
{ vsc: false, node: false },
102+
{ vsc: true, node: false },
103+
{ vsc: true, node: true },
104+
]
105+
throwCombinations.forEach((throws) => {
106+
it(`still writes a file if one of the atomic write methods fails: ${JSON.stringify(throws)}`, async function () {
107+
if (throws.vsc) {
108+
sandbox.stub(fs, 'rename').throws(new Error('Test Error Message VSC'))
109+
}
110+
if (throws.node) {
111+
sandbox.stub(nodeFs.promises, 'rename').throws(new Error('Test Error Message Node'))
112+
}
113+
const filePath = createTestPath('myFileName')
114+
115+
await fs.writeFile(filePath, 'MyContent', { atomic: true })
116+
117+
assert.strictEqual(readFileSync(filePath, 'utf-8'), 'MyContent')
118+
const expectedTelemetry: IdeFileSystem[] = []
119+
if (throws.vsc) {
120+
expectedTelemetry.push({
121+
action: 'writeFile',
122+
result: 'Failed',
123+
reason: 'writeFileAtomicVscRename',
124+
reasonDesc: 'Test Error Message VSC',
125+
})
126+
}
127+
if (throws.node) {
128+
expectedTelemetry.push({
129+
action: 'writeFile',
130+
result: 'Failed',
131+
reason: 'writeFileAtomicNodeRename',
132+
reasonDesc: 'Test Error Message Node',
133+
})
134+
}
135+
if (expectedTelemetry.length > 0) {
136+
testutil.assertTelemetry('ide_fileSystem', expectedTelemetry)
137+
}
138+
})
139+
})
140+
96141
it('throws when existing file + no permission', async function () {
97142
if (isWin()) {
98143
console.log('Skipping since windows does not support mode permissions')
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": "FileNotFound error causing early SSO expiration"
4+
}

0 commit comments

Comments
 (0)