Skip to content
Closed
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
61 changes: 49 additions & 12 deletions packages/@aws-cdk/integ-runner/lib/engines/toolkit-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,21 @@ export class ToolkitLibRunnerEngine implements ICdk {
});

try {
await using lock = await this.toolkit.synth(cx, {
// Explicitly manage the lock lifecycle instead of using 'await using'
// to ensure it's properly released before any subsequent synth operations
const lock = await this.toolkit.synth(cx, {
Comment on lines +106 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't understand this change.

It seems to me that the new code should do the same thing as the old code. What does this change?

validateStacks: false,
stacks: {
strategy: StackSelectionStrategy.ALL_STACKS,
failOnEmpty: false,
},
});
await this.validateRegion(lock);
try {
await this.validateRegion(lock);
} finally {
// Always dispose the lock to avoid conflicts with subsequent synth operations
await lock.dispose();
}
} catch (e: any) {
if (e.message.includes('Missing context keys')) {
// @TODO - silently ignore missing context
Expand Down Expand Up @@ -140,15 +147,26 @@ export class ToolkitLibRunnerEngine implements ICdk {
*/
public async deploy(options: DeployOptions) {
const cx = await this.cx(options);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be await using then?

await this.toolkit.deploy(cx, {
roleArn: options.roleArn,
traceLogs: options.traceLogs,
stacks: this.stackSelector(options),
deploymentMethod: {
method: 'change-set',
},
outputsFile: options.outputsFile ? path.join(this.options.workingDirectory, options.outputsFile) : undefined,
});
try {
await this.toolkit.deploy(cx, {
roleArn: options.roleArn,
traceLogs: options.traceLogs,
stacks: this.stackSelector(options),
deploymentMethod: {
method: 'change-set',
},
outputsFile: options.outputsFile ? path.join(this.options.workingDirectory, options.outputsFile) : undefined,
});
} finally {
// Always dispose the cloud assembly source to release any locks
if (this.isDisposableCloudAssemblySource(cx)) {
await cx.dispose();
}

if (options.output) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why is this necessary?

this.cleanupReadLocks(path.join(this.options.workingDirectory, options.output));
}
}
}

/**
Expand Down Expand Up @@ -202,7 +220,7 @@ export class ToolkitLibRunnerEngine implements ICdk {
// check if the app is a path to existing snapshot and then use it as an assembly directory
const potentialCxPath = path.join(this.options.workingDirectory, options.app);
if (fs.pathExistsSync(potentialCxPath) && fs.statSync(potentialCxPath).isDirectory()) {
return this.toolkit.fromAssemblyDirectory(potentialCxPath);
return this.toolkit.fromAssemblyDirectory(potentialCxPath, { holdLock: false });
}

let outdir;
Expand Down Expand Up @@ -241,6 +259,25 @@ export class ToolkitLibRunnerEngine implements ICdk {
};
}

private cleanupReadLocks(directory: string): void {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems like you're saying "the read locks aren't cleaned up properly, so I will do it myself in a couple of additional places".

Did we figure out already why they weren't being cleaned up?

if (!fs.pathExistsSync(directory)) {
return;
}

const prefix = `read.${process.pid}.`;
for (const entry of fs.readdirSync(directory)) {
if (entry.startsWith(prefix) && entry.endsWith('.lock')) {
fs.removeSync(path.join(directory, entry));
}
}
}

private isDisposableCloudAssemblySource(
source: ICloudAssemblySource,
): source is ICloudAssemblySource & { dispose: () => Promise<void> } {
return typeof (source as { dispose?: unknown }).dispose === 'function';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be called isDisposable?

}

/**
* Check that the regions for the stacks in the CloudAssembly match the regions requested on the engine
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ export class IntegTestRunner extends IntegRunner {
app: path.relative(this.directory, this.snapshotDir),
lookups: expectedTestSuite?.enableLookups,
});
this.cleanupSnapshotReadLocks();
}
// now deploy the "actual" test.
await this.cdk.deploy({
Expand Down Expand Up @@ -605,4 +606,5 @@ export class IntegTestRunner extends IntegRunner {
throw e;
}
}

}
15 changes: 15 additions & 0 deletions packages/@aws-cdk/integ-runner/lib/runner/runner-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ export abstract class IntegRunner {
fs.removeSync(this.snapshotDir);
}

this.cleanupSnapshotReadLocks();

const actualTestSuite = await this.actualTestSuite();

// if lookups are enabled then we need to synth again
Expand Down Expand Up @@ -392,6 +394,19 @@ export abstract class IntegRunner {
}
}

protected cleanupSnapshotReadLocks(): void {
if (!fs.existsSync(this.snapshotDir)) {
return;
}

const prefix = `read.${process.pid}.`;
for (const entry of fs.readdirSync(this.snapshotDir)) {
if (entry.startsWith(prefix) && entry.endsWith('.lock')) {
fs.removeSync(path.join(this.snapshotDir, entry));
}
}
}

protected getContext(additionalContext?: Record<string, any>): Record<string, any> {
return {
...currentlyRecommendedAwsCdkLibFlags(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe('ToolkitLibRunnerEngine - Snapshot Path Handling', () => {

expect(mockedFs.pathExistsSync).toHaveBeenCalledWith(fullSnapshotPath);
expect(mockedFs.statSync).toHaveBeenCalledWith(fullSnapshotPath);
expect(mockToolkit.fromAssemblyDirectory).toHaveBeenCalledWith(fullSnapshotPath);
expect(mockToolkit.fromAssemblyDirectory).toHaveBeenCalledWith(fullSnapshotPath, { holdLock: false });
expect(mockToolkit.fromCdkApp).not.toHaveBeenCalled();
});

Expand Down
31 changes: 29 additions & 2 deletions packages/@aws-cdk/integ-runner/test/engines/toolkit-lib.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Toolkit, BaseCredentials } from '@aws-cdk/toolkit-lib';
import * as fs from 'fs-extra';
import { ToolkitLibRunnerEngine } from '../../lib/engines/toolkit-lib';

jest.mock('@aws-cdk/toolkit-lib');
jest.mock('fs-extra');

const MockedToolkit = Toolkit as jest.MockedClass<typeof Toolkit>;
const MockedBaseCredentials = BaseCredentials as jest.Mocked<typeof BaseCredentials>;
const mockedFs = fs as jest.Mocked<typeof fs>;

describe('ToolkitLibRunnerEngine', () => {
let mockToolkit: jest.Mocked<Toolkit>;
let engine: ToolkitLibRunnerEngine;

beforeEach(() => {
jest.clearAllMocks();
mockedFs.pathExistsSync.mockReturnValue(false);
mockToolkit = {
synth: jest.fn(),
fromCdkApp: jest.fn(),
Expand All @@ -32,7 +36,7 @@ describe('ToolkitLibRunnerEngine', () => {
describe('synth', () => {
it('should use fromCdkApp and produce for fast synthesis', async () => {
const mockCx = { produce: jest.fn() };
const mockLock = { [Symbol.asyncDispose]: jest.fn(), cloudAssembly: { stacksRecursively: [] } };
const mockLock = { dispose: jest.fn(), [Symbol.asyncDispose]: jest.fn(), cloudAssembly: { stacksRecursively: [] } };
mockCx.produce.mockResolvedValue(mockLock);
mockToolkit.fromCdkApp.mockResolvedValue(mockCx as any);
mockToolkit.synth.mockImplementation((cx) => cx.produce() as any);
Expand All @@ -57,7 +61,7 @@ describe('ToolkitLibRunnerEngine', () => {
assetMetadata: false,
},
});
expect(mockLock[Symbol.asyncDispose]).toHaveBeenCalled();
expect(mockLock.dispose).toHaveBeenCalled();
});

it('should handle missing context error silently', async () => {
Expand Down Expand Up @@ -144,6 +148,29 @@ describe('ToolkitLibRunnerEngine', () => {
outputsFile: '/test/dir/assertion-results.json',
});
});

it('cleans up read lock files in output directory after deploy', async () => {
const mockCx = {};
const outputDir = '/test/dir/cdk.out';
const pidPrefix = `read.${process.pid}.`;
const matchingLock = `${pidPrefix}1.lock`;
const otherLock = 'read.99999.1.lock';

mockToolkit.fromCdkApp.mockResolvedValue(mockCx as any);
mockToolkit.deploy.mockResolvedValue({} as any);
mockedFs.pathExistsSync.mockReturnValueOnce(false).mockReturnValue(true);
mockedFs.readdirSync.mockReturnValue([matchingLock, otherLock, 'other.txt'] as any);

await engine.deploy({
app: 'test-app',
stacks: ['stack1'],
output: 'cdk.out',
});

expect(mockedFs.removeSync).toHaveBeenCalledWith(`${outputDir}/${matchingLock}`);
expect(mockedFs.removeSync).not.toHaveBeenCalledWith(`${outputDir}/${otherLock}`);
expect(mockedFs.removeSync).not.toHaveBeenCalledWith(`${outputDir}/other.txt`);
});
});

describe('watch', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ export interface AssemblyDirectoryProps {
* @default true
*/
readonly failOnMissingContext?: boolean;

/**
* Whether to hold a read lock for the lifetime of the produced assembly.
*
* @default true
*/
readonly holdLock?: boolean;
}

/**
Expand Down Expand Up @@ -410,6 +417,25 @@ export abstract class CloudAssemblySourceBuilder {
const readLock = await new RWLock(directory).acquireRead();
try {
const asm = await assemblyFromDirectory(directory, services.ioHelper, props.loadAssemblyOptions);
if (props.holdLock === false) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we not want a read lock? It's there for a reason.

await readLock.release();
const noOpLock = { release: async () => undefined };
const assembly = new ReadableCloudAssembly(asm, noOpLock, { deleteOnDispose: false });
if (assembly.cloudAssembly.manifest.missing && assembly.cloudAssembly.manifest.missing.length > 0) {
if (props.failOnMissingContext ?? true) {
const missingKeysSet = missingContextKeys(assembly.cloudAssembly.manifest.missing);
const missingKeys = Array.from(missingKeysSet);
throw AssemblyError.withCause(
'Assembly contains missing context. ' +
"Make sure all necessary context is already in 'cdk.context.json' by running 'cdk synth' on a machine with sufficient AWS credentials and committing the result. " +
`Missing context keys: '${missingKeys.join(', ')}'`,
'Error producing assembly',
);
}
}
return new CachedCloudAssembly(assembly);
}

const assembly = new ReadableCloudAssembly(asm, readLock, { deleteOnDispose: false });
if (assembly.cloudAssembly.manifest.missing && assembly.cloudAssembly.manifest.missing.length > 0) {
if (props.failOnMissingContext ?? true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,4 +412,19 @@ describe('fromAssemblyDirectory', () => {
// THEN
expect(assembly.cloudAssembly.stacksRecursively.map(s => s.hierarchicalId)).toEqual(['Stack1']);
});

test('does not hold read lock when holdLock is false', async () => {
await using outdir = autoCleanOutDir();
const fixtureDir = path.join(__dirname, '..', '..', '_fixtures', 'two-empty-stacks', 'cdk.out');
await fs.copy(fixtureDir, outdir.dir);

const cx = await toolkit.fromAssemblyDirectory(outdir.dir, { holdLock: false });
const assembly = await cx.produce();

const lockFiles = (await fs.readdir(outdir.dir)).filter(name =>
name.startsWith(`read.${process.pid}.`) && name.endsWith('.lock'));

expect(lockFiles).toEqual([]);
await assembly.dispose();
});
});
Loading