Skip to content

Commit b717dd0

Browse files
authored
fix(appbuilder): Prevent parallel build processes on the same template (aws#6172)
## Context We currently allow multiple build processes to run at the same time for the same template. ## Problem When running a build, it’s possible to start a second build for the same project. This results in both builds failings ## Solution Prevent parallel builds on the same template --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 80c25f3 commit b717dd0

File tree

9 files changed

+141
-32
lines changed

9 files changed

+141
-32
lines changed

packages/core/src/awsService/appBuilder/explorer/openTemplate.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55

66
import { samSyncUrl } from '../../../shared/constants'
77
import { CloudFormationTemplateRegistry } from '../../../shared/fs/templateRegistry'
8-
import { syncMementoRootKey } from '../../../shared/sam/sync'
9-
8+
import { syncMementoRootKey } from '../../../shared/sam/constants'
109
import { createExitPrompter } from '../../../shared/ui/common/exitPrompter'
1110
import { createTemplatePrompter, TemplateItem } from '../../../shared/ui/sam/templatePrompter'
1211
import { Wizard } from '../../../shared/wizards/wizard'

packages/core/src/shared/sam/build.ts

+61-10
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ import { getSpawnEnv } from '../env/resolveEnv'
2222
import {
2323
getErrorCode,
2424
getProjectRoot,
25+
getRecentResponse,
2526
getSamCliPathAndVersion,
2627
getTerminalFromError,
2728
isDotnetRuntime,
2829
updateRecentResponse,
2930
} from './utils'
3031
import { getConfigFileUri, validateSamBuildConfig } from './config'
3132
import { runInTerminal } from './processTerminal'
33+
import { buildMementoRootKey, buildProcessMementoRootKey, globalIdentifier } from './constants'
3234
import { SemVer } from 'semver'
3335

34-
const buildMementoRootKey = 'samcli.build.params'
3536
export interface BuildParams {
3637
readonly template: TemplateItem
3738
readonly projectRoot: vscode.Uri
@@ -208,6 +209,9 @@ export async function runBuild(arg?: TreeNode): Promise<SamBuildResult> {
208209
throw new CancellationError('user')
209210
}
210211

212+
throwIfTemplateIsBeingBuilt(params.template.uri.path)
213+
await registerTemplateBuild(params.template.uri.path)
214+
211215
const projectRoot = params.projectRoot
212216

213217
const defaultFlags: string[] = ['--cached', '--parallel', '--save-params', '--use-container']
@@ -233,16 +237,30 @@ export async function runBuild(arg?: TreeNode): Promise<SamBuildResult> {
233237
await updateRecentResponse(buildMementoRootKey, 'global', 'templatePath', templatePath)
234238

235239
try {
236-
// Create a child process to run the SAM build command
237-
const buildProcess = new ChildProcess(samCliPath, ['build', ...buildFlags], {
238-
spawnOptions: await addTelemetryEnvVar({
239-
cwd: params.projectRoot.fsPath,
240-
env: await getSpawnEnv(process.env),
241-
}),
242-
})
240+
await vscode.window.withProgress(
241+
{
242+
cancellable: true,
243+
location: vscode.ProgressLocation.Notification,
244+
},
245+
async (progress, token) => {
246+
token.onCancellationRequested(async () => {
247+
throw new CancellationError('user')
248+
})
243249

244-
// Run SAM build in Terminal
245-
await runInTerminal(buildProcess, 'build')
250+
progress.report({ message: `Building SAM template at ${params.template.uri.path}` })
251+
252+
// Create a child process to run the SAM build command
253+
const buildProcess = new ChildProcess(samCliPath, ['build', ...buildFlags], {
254+
spawnOptions: await addTelemetryEnvVar({
255+
cwd: params.projectRoot.fsPath,
256+
env: await getSpawnEnv(process.env),
257+
}),
258+
})
259+
260+
// Run SAM build in Terminal
261+
await runInTerminal(buildProcess, 'build')
262+
}
263+
)
246264

247265
return {
248266
isSuccess: true,
@@ -252,6 +270,8 @@ export async function runBuild(arg?: TreeNode): Promise<SamBuildResult> {
252270
details: { terminal: getTerminalFromError(error), ...resolveBuildArgConflict(buildFlags) },
253271
code: getErrorCode(error),
254272
})
273+
} finally {
274+
await unregisterTemplateBuild(params.template.uri.path)
255275
}
256276
}
257277

@@ -293,3 +313,34 @@ export async function resolveBuildFlags(buildFlags: string[], samCliVersion: Sem
293313
}
294314
return buildFlags
295315
}
316+
317+
/**
318+
* Returns true if there's an ongoing build process for the provided template, false otherwise
319+
* @Param templatePath The path to the template.yaml file
320+
*/
321+
function isBuildInProgress(templatePath: string): boolean {
322+
const expirationDate = getRecentResponse(buildProcessMementoRootKey, globalIdentifier, templatePath)
323+
if (expirationDate) {
324+
return Date.now() < parseInt(expirationDate)
325+
}
326+
return false
327+
}
328+
329+
/**
330+
* Throws an error if there's a build in progress for the provided template
331+
* @Param templatePath The path to the template.yaml file
332+
*/
333+
export function throwIfTemplateIsBeingBuilt(templatePath: string) {
334+
if (isBuildInProgress(templatePath)) {
335+
throw new ToolkitError('This template is already being built', { code: 'BuildInProgress' })
336+
}
337+
}
338+
339+
export async function registerTemplateBuild(templatePath: string) {
340+
const expirationDate = Date.now() + 5 * 60 * 1000 // five minutes
341+
await updateRecentResponse(buildProcessMementoRootKey, globalIdentifier, templatePath, expirationDate.toString())
342+
}
343+
344+
export async function unregisterTemplateBuild(templatePath: string) {
345+
await updateRecentResponse(buildProcessMementoRootKey, globalIdentifier, templatePath, undefined)
346+
}
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export const buildProcessMementoRootKey = 'samcli.build.processes'
7+
export const globalIdentifier = 'global'
8+
export const buildMementoRootKey = 'samcli.build.params'
9+
export const deployMementoRootKey = 'samcli.deploy.params'
10+
export const syncMementoRootKey = 'samcli.sync.params'

packages/core/src/shared/sam/deploy.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { TemplateItem, createTemplatePrompter } from '../ui/sam/templatePrompter
2626
import { createDeployParamsSourcePrompter, ParamsSource } from '../ui/sam/paramsSourcePrompter'
2727
import { getErrorCode, getProjectRoot, getSamCliPathAndVersion, getSource, updateRecentResponse } from './utils'
2828
import { runInTerminal } from './processTerminal'
29+
import { deployMementoRootKey } from './constants'
2930
import {
3031
TemplateParametersForm,
3132
TemplateParametersWizard,
@@ -64,7 +65,6 @@ function getDeployEntryPoint(arg: vscode.Uri | AWSTreeNodeBase | TreeNode | unde
6465
return SamDeployEntryPoints.CommandPalette
6566
}
6667
}
67-
const deployMementoRootKey = 'samcli.deploy.params'
6868

6969
type DeployResult = {
7070
isSuccess: boolean

packages/core/src/shared/sam/sync.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { ParamsSource, createSyncParamsSourcePrompter } from '../ui/sam/paramsSo
5151
import { createEcrPrompter } from '../ui/sam/ecrPrompter'
5252
import { BucketSource, createBucketNamePrompter, createBucketSourcePrompter } from '../ui/sam/bucketPrompter'
5353
import { runInTerminal } from './processTerminal'
54+
import { syncMementoRootKey } from './constants'
5455
import {
5556
TemplateParametersForm,
5657
TemplateParametersWizard,
@@ -73,8 +74,6 @@ export interface SyncParams {
7374
readonly syncFlags?: string
7475
}
7576

76-
export const syncMementoRootKey = 'samcli.sync.params'
77-
7877
// TODO: hook this up so it prompts the user when more than 1 environment is present in `samconfig.toml`
7978
export function createEnvironmentPrompter(config: SamConfig, environments = config.listEnvironments()) {
8079
const recentEnvironmentName = getRecentResponse(syncMementoRootKey, config.location.fsPath, 'environmentName')

packages/core/src/shared/sam/utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export async function updateRecentResponse(
108108
getLogger().warn(`sam: unable to save response at key "${key}": %s`, err)
109109
}
110110
}
111+
111112
export function getSamCliErrorMessage(stderr: string): string {
112113
// Split the stderr string by newline, filter out empty lines, and get the last line
113114
const lines = stderr

packages/core/src/test/shared/sam/build.test.ts

+61-16
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
ParamsSource,
1919
resolveBuildFlags,
2020
runBuild,
21+
SamBuildResult,
2122
} from '../../../shared/sam/build'
2223
import { TreeNode } from '../../../shared/treeview/resourceTreeDataProvider'
2324
import { createWizardTester } from '../wizards/wizardTestUtils'
@@ -34,6 +35,7 @@ import { samconfigCompleteData, validTemplateData } from './samTestUtils'
3435
import { CloudFormationTemplateRegistry } from '../../../shared/fs/templateRegistry'
3536
import { getTestWindow } from '../vscode/window'
3637
import { CancellationError } from '../../../shared/utilities/timeoutUtils'
38+
import { SamAppLocation } from '../../../awsService/appBuilder/explorer/samProject'
3739
import { SemVer } from 'semver'
3840

3941
describe('SAM BuildWizard', async function () {
@@ -423,22 +425,8 @@ describe('SAM runBuild', () => {
423425
verifyCorrectDependencyCall()
424426
})
425427

426-
it('[entry: appbuilder node] with default flags should instantiate correct process in terminal', async () => {
427-
const prompterTester = PrompterTester.init()
428-
.handleQuickPick('Specify parameter source for build', async (quickPick) => {
429-
// Need sometime to wait for the template to search for template file
430-
await quickPick.untilReady()
431-
432-
assert.strictEqual(quickPick.items.length, 2)
433-
const items = quickPick.items
434-
assert.strictEqual(quickPick.items.length, 2)
435-
assert.deepStrictEqual(items[0], { data: ParamsSource.Specify, label: 'Specify build flags' })
436-
assert.deepStrictEqual(items[1].label, 'Use default values')
437-
quickPick.acceptItem(quickPick.items[1])
438-
})
439-
.build()
440-
441-
// Invoke sync command from command palette
428+
it('[entry: appbuilder node] with default flags should instantiate correct process in terminal and show progress notification', async () => {
429+
const prompterTester = getPrompterTester()
442430
const expectedSamAppLocation = {
443431
workspaceFolder: workspaceFolder,
444432
samTemplateUri: templateFile,
@@ -447,6 +435,10 @@ describe('SAM runBuild', () => {
447435

448436
await runBuild(new AppNode(expectedSamAppLocation))
449437

438+
getTestWindow()
439+
.getFirstMessage()
440+
.assertProgress(`Building SAM template at ${expectedSamAppLocation.samTemplateUri.path}`)
441+
450442
assert.deepEqual(mockChildProcessClass.getCall(0).args, [
451443
'sam-cli-path',
452444
[
@@ -472,6 +464,27 @@ describe('SAM runBuild', () => {
472464
prompterTester.assertCallAll()
473465
})
474466

467+
it('[entry: appbuilder node] should throw an error when running two build processes in parallel for the same template', async () => {
468+
const prompterTester = getPrompterTester()
469+
const expectedSamAppLocation = {
470+
workspaceFolder: workspaceFolder,
471+
samTemplateUri: templateFile,
472+
projectRoot: projectRoot,
473+
}
474+
await assert.rejects(
475+
async () => {
476+
await runInParallel(expectedSamAppLocation)
477+
},
478+
(e: any) => {
479+
assert.strictEqual(e instanceof ToolkitError, true)
480+
assert.strictEqual(e.message, 'This template is already being built')
481+
assert.strictEqual(e.code, 'BuildInProgress')
482+
return true
483+
}
484+
)
485+
prompterTester.assertCallAll(undefined, 2)
486+
})
487+
475488
it('[entry: command palette] use samconfig should instantiate correct process in terminal', async () => {
476489
const samconfigFile = vscode.Uri.file(await testFolder.write('samconfig.toml', samconfigCompleteData))
477490

@@ -587,6 +600,38 @@ describe('SAM runBuild', () => {
587600
})
588601
})
589602

603+
async function runInParallel(samLocation: SamAppLocation): Promise<[SamBuildResult, SamBuildResult]> {
604+
return Promise.all([runBuild(new AppNode(samLocation)), delayedRunBuild(samLocation)])
605+
}
606+
607+
// We add a small delay to avoid the unlikely but possible race condition.
608+
async function delayedRunBuild(samLocation: SamAppLocation): Promise<SamBuildResult> {
609+
return new Promise(async (resolve, reject) => {
610+
// Add a small delay before returning the build promise
611+
setTimeout(() => {
612+
// Do nothing, just let the delay pass
613+
}, 20)
614+
615+
const buildPromise = runBuild(new AppNode(samLocation))
616+
buildPromise.then(resolve).catch(reject)
617+
})
618+
}
619+
620+
function getPrompterTester() {
621+
return PrompterTester.init()
622+
.handleQuickPick('Specify parameter source for build', async (quickPick) => {
623+
// Need sometime to wait for the template to search for template file
624+
await quickPick.untilReady()
625+
626+
assert.strictEqual(quickPick.items.length, 2)
627+
const items = quickPick.items
628+
assert.strictEqual(quickPick.items.length, 2)
629+
assert.deepStrictEqual(items[0], { data: ParamsSource.Specify, label: 'Specify build flags' })
630+
assert.deepStrictEqual(items[1].label, 'Use default values')
631+
quickPick.acceptItem(quickPick.items[1])
632+
})
633+
.build()
634+
}
590635
function testResolveBuildFlags(
591636
sandbox: sinon.SinonSandbox,
592637
parsedVersion: SemVer,

packages/core/src/testInteg/sam.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ describe('SAM Integration Tests', async function () {
551551
describe('SAM Build', async function () {
552552
let workspaceFolder: vscode.WorkspaceFolder
553553
// We're testing only one case (python 3.10 (ZIP)) on the integration tests. More niche cases are handled as unit tests.
554-
const scenarioIndex = 2
554+
const scenarioIndex = 3
555555
const scenario = scenarios[scenarioIndex]
556556
let testRoot: string
557557
let sandbox: sinon.SinonSandbox
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Bug Fix",
3+
"description": "SAM build: prevent running multiple build processes for the same template"
4+
}

0 commit comments

Comments
 (0)