From e0c42664ebbdd9eb92026bb6aff2ac61db354d54 Mon Sep 17 00:00:00 2001 From: dodoshell <326978213+dodoshell@users.noreply.github.com> Date: Thu, 12 Nov 2026 11:13:55 +0100 Subject: [PATCH 1/4] fix(lib): reuse a bundled asset's artifact when its hash is unchanged A synth removed the asset's whole folder, including the artifact for the current hash, so every synth reran every bundler. Only the folders of other hashes are removed now, and a bundled asset hashed before the build (SOURCE or CUSTOM) keeps its existing artifact instead of rebuilding. Staging writes to a sibling `.staging` folder that is renamed into place once it succeeds, so a failed or interrupted build never leaves a folder a later synth would reuse. Fixes #582 --- packages/cdktn/src/terraform-asset.ts | 47 ++++++-- .../cdktn/test/canonical-asset-hash.test.ts | 102 ++++++++++++++++++ 2 files changed, 138 insertions(+), 11 deletions(-) diff --git a/packages/cdktn/src/terraform-asset.ts b/packages/cdktn/src/terraform-asset.ts index 01b6c3497..7c4afc00e 100644 --- a/packages/cdktn/src/terraform-asset.ts +++ b/packages/cdktn/src/terraform-asset.ts @@ -136,6 +136,8 @@ export class TerraformAsset extends Construct implements IAsset { private readonly staging: AssetStaging; // set when a bundler builds the artifact; names a single-file result private readonly bundler?: IAssetBundler; + // true when the hash is known before the build, so a staged artifact can be reused + private readonly reusesStagedArtifact: boolean; /** * A Terraform Asset takes a file or directory outside of the CDK Terrain context and moves it into it. @@ -177,6 +179,8 @@ export class TerraformAsset extends Construct implements IAsset { } this.bundler = config.bundler; + this.reusesStagedArtifact = + !!config.bundler && config.assetHashType !== AssetHashType.OUTPUT; const outputFileName = config.bundler?.outputFileName; if (outputFileName !== undefined) { @@ -270,21 +274,42 @@ export class TerraformAsset extends Construct implements IAsset { "..", ); - // Cleanup existing assets - const previousVersionsFolder = path.join(basePath, this.namedFolder); - if (fs.existsSync(previousVersionsFolder)) { - fs.rmSync(previousVersionsFolder, { recursive: true }); + // Cleanup assets staged for previous hashes + const namedFolder = path.join(basePath, this.namedFolder); + if (fs.existsSync(namedFolder)) { + for (const entry of fs.readdirSync(namedFolder)) { + if (entry !== this.assetHash) { + fs.rmSync(path.join(namedFolder, entry), { + recursive: true, + force: true, + }); + } + } } + // SOURCE and CUSTOM hashes are known before the build, so an artifact + // already staged for this hash is what the bundler would build again. const targetPath = path.join(basePath, this.path); - const packaging = this.packaging; - - if (packaging.producesDirectory) { - fs.mkdirSync(targetPath, { recursive: true }); - } else { - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + if (this.reusesStagedArtifact && fs.existsSync(targetPath)) { + return; } - this.staging.stage(targetPath); + // Stage next to the hash folder and move it into place only once staging + // succeeded, so a failed or interrupted build never leaves a folder that + // a later synth would reuse. + const hashFolder = path.join(namedFolder, this.assetHash); + const stagingFolder = `${hashFolder}.staging`; + fs.mkdirSync(stagingFolder, { recursive: true }); + try { + this.staging.stage( + this.packaging.producesDirectory + ? stagingFolder + : path.join(stagingFolder, this.fileName), + ); + fs.rmSync(hashFolder, { recursive: true, force: true }); + fs.renameSync(stagingFolder, hashFolder); + } finally { + fs.rmSync(stagingFolder, { recursive: true, force: true }); + } } } diff --git a/packages/cdktn/test/canonical-asset-hash.test.ts b/packages/cdktn/test/canonical-asset-hash.test.ts index 4f99657c9..0d285f032 100644 --- a/packages/cdktn/test/canonical-asset-hash.test.ts +++ b/packages/cdktn/test/canonical-asset-hash.test.ts @@ -14,6 +14,7 @@ import { AssetHashType, BundleResult, IAsset, + IAssetBundler, } from "../src"; import { CANONICAL_ASSET_HASHES } from "../src/features"; import { TerraformModuleAsset } from "../src/terraform-module-asset"; @@ -899,6 +900,107 @@ describe("TerraformAsset stages inside the stack's own directory (#380)", () => }); }); +describe("TerraformAsset reuses a bundled artifact across synths (#582)", () => { + let srcDir: string; + let outdir: string; + + beforeEach(() => { + srcDir = createTempDir(); + fs.writeFileSync(path.join(srcDir, "a.txt"), "content"); + outdir = createTempDir(); + }); + + afterEach(() => { + fs.rmSync(srcDir, { recursive: true, force: true }); + fs.rmSync(outdir, { recursive: true, force: true }); + }); + + // Each synth gets a fresh App writing to the same outdir, as consecutive + // `cdktn synth` runs do. + const build = (type: AssetType, bundler: IAssetBundler) => { + const app = Testing.app({ + outdir, + context: { [CANONICAL_ASSET_HASHES]: "true" }, + }); + const s = new TerraformStack(app, "s"); + const asset = new TerraformAsset(s, "asset", { + path: srcDir, + type, + bundler, + }); + return { app, asset, stackDir: path.join(outdir, "stacks", s.node.id) }; + }; + + const synth = (type: AssetType, bundler: IAssetBundler) => { + const built = build(type, bundler); + built.app.synth(); + return built; + }; + + const countingBundler = (bundlerKey?: string) => { + const bundler = { + runs: 0, + bundlerKey, + bundle: (opts: { outputDir: string }) => { + bundler.runs++; + fs.writeFileSync(path.join(opts.outputDir, "index.txt"), "built"); + return BundleResult.directory(opts.outputDir); + }, + }; + return bundler; + }; + + test("a second synth with the same hash does not rerun the bundler", () => { + const bundler = countingBundler(); + + const first = synth(AssetType.ARCHIVE, bundler); + const second = synth(AssetType.ARCHIVE, bundler); + + expect(second.asset.assetHash).toBe(first.asset.assetHash); + expect(bundler.runs).toBe(1); + expect(fs.existsSync(path.join(second.stackDir, second.asset.path))).toBe( + true, + ); + }); + + test("a changed hash reruns the bundler and removes the old artifact", () => { + const v1 = countingBundler("v1"); + const v2 = countingBundler("v2"); + + const first = synth(AssetType.ARCHIVE, v1); + const second = synth(AssetType.ARCHIVE, v2); + + expect(second.asset.assetHash).not.toBe(first.asset.assetHash); + expect(v2.runs).toBe(1); + const namedFolder = path.dirname( + path.dirname(path.join(second.stackDir, second.asset.path)), + ); + expect(fs.readdirSync(namedFolder)).toEqual([second.asset.assetHash]); + }); + + test("a failed build leaves no artifact for the next synth to reuse", () => { + const failing = { + bundle: (opts: { outputDir: string }) => { + fs.writeFileSync(path.join(opts.outputDir, "partial.txt"), "partial"); + throw new Error("build failed"); + }, + }; + + const failed = build(AssetType.DIRECTORY, failing); + expect(() => failed.app.synth()).toThrow(/build failed/); + expect(fs.existsSync(path.join(failed.stackDir, failed.asset.path))).toBe( + false, + ); + + const bundler = countingBundler(); + const { asset, stackDir } = synth(AssetType.DIRECTORY, bundler); + expect(bundler.runs).toBe(1); + expect(fs.readdirSync(path.join(stackDir, asset.path))).toEqual([ + "index.txt", + ]); + }); +}); + describe("TerraformModuleAsset with the canonicalAssetHashes flag", () => { let rootDir: string; let moduleA: string; From d31763d4111f2d984cec0390ead603b8add0c1ee Mon Sep 17 00:00:00 2001 From: dodoshell <326978213+dodoshell@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:27:28 +0100 Subject: [PATCH 2/4] fix(lib): reuse a bundled artifact only when its packaging matches The hash does not depend on the asset type, so an artifact staged as ARCHIVE or FILE was reused after the type changed to DIRECTORY, whose path is the hash folder itself. A `.json` file next to the hash folder now records the type and file name of the staged artifact. It is written once the staged folder is in place, and a synth reuses the artifact only when it matches. Only bundled SOURCE and CUSTOM assets, which can reuse their artifact, stage into `.staging` and rename it into place. The rename retries briefly on EPERM, EACCES and EBUSY, which Windows returns while antivirus or the indexer has a file in the folder open. Other assets are staged in place, as before. The `extraHash` and `bundler` docs now say when a bundled asset keeps its old artifact and how to force a rebuild. --- packages/cdktn/src/private/fs.ts | 32 +++++++++++++ packages/cdktn/src/terraform-asset.ts | 47 +++++++++++++++--- .../cdktn/test/canonical-asset-hash.test.ts | 48 ++++++++++++++++++- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/packages/cdktn/src/private/fs.ts b/packages/cdktn/src/private/fs.ts index 4f86ca102..82543ca05 100644 --- a/packages/cdktn/src/private/fs.ts +++ b/packages/cdktn/src/private/fs.ts @@ -443,3 +443,35 @@ export function findFileAboveCwd( return null; } + +const RENAME_ATTEMPTS = 5; +const RENAME_BACKOFF_MS = 50; +const RENAME_RETRY_CODES = ["EPERM", "EACCES", "EBUSY"]; + +/** + * Rename `src` to `dest`, retrying with a short backoff while Windows refuses + * because a file inside is briefly open (antivirus, search indexer). + * @param src - path to rename + * @param dest - new path + */ +export function renameSyncWithRetry(src: string, dest: string): void { + for (let attempt = 1; ; attempt++) { + try { + fs.renameSync(src, dest); + return; + } catch (err: any) { + if ( + attempt >= RENAME_ATTEMPTS || + !RENAME_RETRY_CODES.includes(err.code) + ) { + throw err; + } + Atomics.wait( + new Int32Array(new SharedArrayBuffer(4)), + 0, + 0, + RENAME_BACKOFF_MS * 2 ** (attempt - 1), + ); + } + } +} diff --git a/packages/cdktn/src/terraform-asset.ts b/packages/cdktn/src/terraform-asset.ts index 7c4afc00e..08d63d7c3 100644 --- a/packages/cdktn/src/terraform-asset.ts +++ b/packages/cdktn/src/terraform-asset.ts @@ -12,7 +12,7 @@ import { } from "./assets"; import { AssetStaging } from "./asset-staging"; import { IIgnoreStrategy } from "./ignore-strategy"; -import { findFileAboveCwd } from "./private/fs"; +import { findFileAboveCwd, renameSyncWithRetry } from "./private/fs"; import { ISynthesisSession } from "./synthesize"; import { addCustomSynthesis } from "./synthesize/synthesizer"; import { TerraformStack } from "./terraform-stack"; @@ -74,6 +74,11 @@ export interface TerraformAssetConfig { * Extra information to fold into the hash (e.g. build instructions and * other inputs). * + * A bundled asset reuses the artifact an earlier synth staged for the same + * hash, so set this for build inputs the hash cannot see: files outside + * `path`, a mutable Docker tag such as `node:20`, or the options of a + * bundler without a `bundlerKey`. + * * @default - no extra hash */ readonly extraHash?: string; @@ -87,6 +92,10 @@ export interface TerraformAssetConfig { * The bundler's output shape must match `type`. See * `AssetStagingOptions.bundler`. * + * Under `SOURCE` or `CUSTOM` hashing, a synth keeps the artifact already + * staged for the same hash and `type`. See `extraHash` for inputs the hash + * cannot see; deleting the output directory also forces a rebuild. + * * @default - the source is staged verbatim, with no build step */ readonly bundler?: IAssetBundler; @@ -276,9 +285,13 @@ export class TerraformAsset extends Construct implements IAsset { // Cleanup assets staged for previous hashes const namedFolder = path.join(basePath, this.namedFolder); + const hashFolder = path.join(namedFolder, this.assetHash); + // Records how the artifact in the hash folder was packaged, which the + // hash does not cover: ARCHIVE and DIRECTORY share a hash. + const packagingFile = `${hashFolder}.json`; if (fs.existsSync(namedFolder)) { for (const entry of fs.readdirSync(namedFolder)) { - if (entry !== this.assetHash) { + if (entry !== this.assetHash && entry !== `${this.assetHash}.json`) { fs.rmSync(path.join(namedFolder, entry), { recursive: true, force: true, @@ -288,16 +301,37 @@ export class TerraformAsset extends Construct implements IAsset { } // SOURCE and CUSTOM hashes are known before the build, so an artifact - // already staged for this hash is what the bundler would build again. + // already staged for this hash and packaging is what the bundler would + // build again. const targetPath = path.join(basePath, this.path); - if (this.reusesStagedArtifact && fs.existsSync(targetPath)) { + const stagedPackaging = JSON.stringify({ + type: AssetType[this.type], + fileName: this.fileName, + }); + if ( + this.reusesStagedArtifact && + fs.existsSync(targetPath) && + fs.existsSync(packagingFile) && + fs.readFileSync(packagingFile, "utf8") === stagedPackaging + ) { + return; + } + fs.rmSync(packagingFile, { force: true }); + + if (!this.reusesStagedArtifact) { + fs.rmSync(hashFolder, { recursive: true, force: true }); + if (this.packaging.producesDirectory) { + fs.mkdirSync(targetPath, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + } + this.staging.stage(targetPath); return; } // Stage next to the hash folder and move it into place only once staging // succeeded, so a failed or interrupted build never leaves a folder that // a later synth would reuse. - const hashFolder = path.join(namedFolder, this.assetHash); const stagingFolder = `${hashFolder}.staging`; fs.mkdirSync(stagingFolder, { recursive: true }); try { @@ -307,9 +341,10 @@ export class TerraformAsset extends Construct implements IAsset { : path.join(stagingFolder, this.fileName), ); fs.rmSync(hashFolder, { recursive: true, force: true }); - fs.renameSync(stagingFolder, hashFolder); + renameSyncWithRetry(stagingFolder, hashFolder); } finally { fs.rmSync(stagingFolder, { recursive: true, force: true }); } + fs.writeFileSync(packagingFile, stagedPackaging); } } diff --git a/packages/cdktn/test/canonical-asset-hash.test.ts b/packages/cdktn/test/canonical-asset-hash.test.ts index 0d285f032..b505e0d87 100644 --- a/packages/cdktn/test/canonical-asset-hash.test.ts +++ b/packages/cdktn/test/canonical-asset-hash.test.ts @@ -950,6 +950,15 @@ describe("TerraformAsset reuses a bundled artifact across synths (#582)", () => return bundler; }; + const fileBundler: IAssetBundler = { + outputFileName: "out.txt", + bundle: (opts) => { + const file = path.join(opts.outputDir, "out.txt"); + fs.writeFileSync(file, "built"); + return BundleResult.file(file); + }, + }; + test("a second synth with the same hash does not rerun the bundler", () => { const bundler = countingBundler(); @@ -975,9 +984,30 @@ describe("TerraformAsset reuses a bundled artifact across synths (#582)", () => const namedFolder = path.dirname( path.dirname(path.join(second.stackDir, second.asset.path)), ); - expect(fs.readdirSync(namedFolder)).toEqual([second.asset.assetHash]); + expect(fs.readdirSync(namedFolder)).toEqual([ + second.asset.assetHash, + `${second.asset.assetHash}.json`, + ]); }); + test.each([ + ["ARCHIVE", AssetType.ARCHIVE, countingBundler()], + ["FILE", AssetType.FILE, fileBundler], + ])( + "changing the type from %s to DIRECTORY rebuilds the artifact", + (_, type, previous) => { + const first = synth(type, previous); + const bundler = countingBundler(); + const second = synth(AssetType.DIRECTORY, bundler); + + expect(second.asset.assetHash).toBe(first.asset.assetHash); + expect(bundler.runs).toBe(1); + expect( + fs.readdirSync(path.join(second.stackDir, second.asset.path)), + ).toEqual(["index.txt"]); + }, + ); + test("a failed build leaves no artifact for the next synth to reuse", () => { const failing = { bundle: (opts: { outputDir: string }) => { @@ -999,6 +1029,22 @@ describe("TerraformAsset reuses a bundled artifact across synths (#582)", () => "index.txt", ]); }); + + test("a rename refused with EPERM is retried", () => { + const spy = jest.spyOn(fs, "renameSync").mockImplementationOnce(() => { + throw Object.assign(new Error("operation not permitted"), { + code: "EPERM", + }); + }); + try { + const { asset, stackDir } = synth(AssetType.DIRECTORY, countingBundler()); + expect(fs.readdirSync(path.join(stackDir, asset.path))).toEqual([ + "index.txt", + ]); + } finally { + spy.mockRestore(); + } + }); }); describe("TerraformModuleAsset with the canonicalAssetHashes flag", () => { From cc63d9efb9095b48a5c0f1f4f24a81c2d6a30c8d Mon Sep 17 00:00:00 2001 From: dodoshell <326978213+dodoshell@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:22:41 +0100 Subject: [PATCH 3/4] docs(lib): note that extraHash is ignored when assetHash is set --- packages/cdktn/src/terraform-asset.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cdktn/src/terraform-asset.ts b/packages/cdktn/src/terraform-asset.ts index 08d63d7c3..fac908b68 100644 --- a/packages/cdktn/src/terraform-asset.ts +++ b/packages/cdktn/src/terraform-asset.ts @@ -77,7 +77,8 @@ export interface TerraformAssetConfig { * A bundled asset reuses the artifact an earlier synth staged for the same * hash, so set this for build inputs the hash cannot see: files outside * `path`, a mutable Docker tag such as `node:20`, or the options of a - * bundler without a `bundlerKey`. + * bundler without a `bundlerKey`. It is ignored when `assetHash` is set; + * change `assetHash` instead. * * @default - no extra hash */ @@ -92,9 +93,9 @@ export interface TerraformAssetConfig { * The bundler's output shape must match `type`. See * `AssetStagingOptions.bundler`. * - * Under `SOURCE` or `CUSTOM` hashing, a synth keeps the artifact already - * staged for the same hash and `type`. See `extraHash` for inputs the hash - * cannot see; deleting the output directory also forces a rebuild. + * Under `SOURCE` hashing or a fixed `assetHash`, a synth keeps the artifact + * already staged for the same hash and `type`. See `extraHash` for inputs + * the hash cannot see; deleting the output directory also forces a rebuild. * * @default - the source is staged verbatim, with no build step */ From 6b5ac3abd576221b638b7a7ffd263f63e6c37274 Mon Sep 17 00:00:00 2001 From: dodoshell <326978213+dodoshell@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:53:55 +0100 Subject: [PATCH 4/4] fix(lib): retry asset cleanup and keep the build error if cleanup fails Removing an asset's folders for other hashes, the hash folder before the rename, and the staging folder after a failure now retry on Windows file locks, like the rename. A cleanup that still fails no longer replaces the build error; the next synth sweeps the leftover staging folder. A directory artifact's `.json` no longer records a file name, which a directory has no use for, so moving its source no longer rebuilds it. The `extraHash` docs now name the choice of bundler as an input the hash cannot see. --- packages/cdktn/src/private/fs.ts | 25 ++++++++--- packages/cdktn/src/terraform-asset.ts | 32 +++++++++----- .../cdktn/test/canonical-asset-hash.test.ts | 44 +++++++++++++++++++ 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/packages/cdktn/src/private/fs.ts b/packages/cdktn/src/private/fs.ts index 82543ca05..44520181a 100644 --- a/packages/cdktn/src/private/fs.ts +++ b/packages/cdktn/src/private/fs.ts @@ -444,8 +444,8 @@ export function findFileAboveCwd( return null; } -const RENAME_ATTEMPTS = 5; -const RENAME_BACKOFF_MS = 50; +const RETRY_ATTEMPTS = 5; +const RETRY_BACKOFF_MS = 50; const RENAME_RETRY_CODES = ["EPERM", "EACCES", "EBUSY"]; /** @@ -460,18 +460,29 @@ export function renameSyncWithRetry(src: string, dest: string): void { fs.renameSync(src, dest); return; } catch (err: any) { - if ( - attempt >= RENAME_ATTEMPTS || - !RENAME_RETRY_CODES.includes(err.code) - ) { + if (attempt >= RETRY_ATTEMPTS || !RENAME_RETRY_CODES.includes(err.code)) { throw err; } Atomics.wait( new Int32Array(new SharedArrayBuffer(4)), 0, 0, - RENAME_BACKOFF_MS * 2 ** (attempt - 1), + RETRY_BACKOFF_MS * 2 ** (attempt - 1), ); } } } + +/** + * Remove `target` recursively, letting Node retry while Windows briefly holds + * a file inside open. + * @param target - path to remove + */ +export function rmSyncWithRetry(target: string): void { + fs.rmSync(target, { + recursive: true, + force: true, + maxRetries: RETRY_ATTEMPTS - 1, + retryDelay: RETRY_BACKOFF_MS, + }); +} diff --git a/packages/cdktn/src/terraform-asset.ts b/packages/cdktn/src/terraform-asset.ts index fac908b68..3a742ce53 100644 --- a/packages/cdktn/src/terraform-asset.ts +++ b/packages/cdktn/src/terraform-asset.ts @@ -12,7 +12,11 @@ import { } from "./assets"; import { AssetStaging } from "./asset-staging"; import { IIgnoreStrategy } from "./ignore-strategy"; -import { findFileAboveCwd, renameSyncWithRetry } from "./private/fs"; +import { + findFileAboveCwd, + renameSyncWithRetry, + rmSyncWithRetry, +} from "./private/fs"; import { ISynthesisSession } from "./synthesize"; import { addCustomSynthesis } from "./synthesize/synthesizer"; import { TerraformStack } from "./terraform-stack"; @@ -76,9 +80,9 @@ export interface TerraformAssetConfig { * * A bundled asset reuses the artifact an earlier synth staged for the same * hash, so set this for build inputs the hash cannot see: files outside - * `path`, a mutable Docker tag such as `node:20`, or the options of a - * bundler without a `bundlerKey`. It is ignored when `assetHash` is set; - * change `assetHash` instead. + * `path`, a mutable Docker tag such as `node:20`, or which bundler is used + * and its options when it has no `bundlerKey`. It is ignored when + * `assetHash` is set; change `assetHash` instead. * * @default - no extra hash */ @@ -293,10 +297,7 @@ export class TerraformAsset extends Construct implements IAsset { if (fs.existsSync(namedFolder)) { for (const entry of fs.readdirSync(namedFolder)) { if (entry !== this.assetHash && entry !== `${this.assetHash}.json`) { - fs.rmSync(path.join(namedFolder, entry), { - recursive: true, - force: true, - }); + rmSyncWithRetry(path.join(namedFolder, entry)); } } } @@ -305,9 +306,11 @@ export class TerraformAsset extends Construct implements IAsset { // already staged for this hash and packaging is what the bundler would // build again. const targetPath = path.join(basePath, this.path); + // A directory artifact has no file name, so moving its source does not + // rebuild it. const stagedPackaging = JSON.stringify({ type: AssetType[this.type], - fileName: this.fileName, + fileName: this.packaging.producesDirectory ? undefined : this.fileName, }); if ( this.reusesStagedArtifact && @@ -341,10 +344,15 @@ export class TerraformAsset extends Construct implements IAsset { ? stagingFolder : path.join(stagingFolder, this.fileName), ); - fs.rmSync(hashFolder, { recursive: true, force: true }); + rmSyncWithRetry(hashFolder); renameSyncWithRetry(stagingFolder, hashFolder); - } finally { - fs.rmSync(stagingFolder, { recursive: true, force: true }); + } catch (err) { + try { + rmSyncWithRetry(stagingFolder); + } catch { + // The next synth sweeps it; the build error is the one to report. + } + throw err; } fs.writeFileSync(packagingFile, stagedPackaging); } diff --git a/packages/cdktn/test/canonical-asset-hash.test.ts b/packages/cdktn/test/canonical-asset-hash.test.ts index b505e0d87..d66d9a61c 100644 --- a/packages/cdktn/test/canonical-asset-hash.test.ts +++ b/packages/cdktn/test/canonical-asset-hash.test.ts @@ -1030,6 +1030,50 @@ describe("TerraformAsset reuses a bundled artifact across synths (#582)", () => ]); }); + test("a failed cleanup does not hide the build error", () => { + const failing = { + bundle: () => { + throw new Error("build failed"); + }, + }; + const rmSync = fs.rmSync; + const spy = jest.spyOn(fs, "rmSync").mockImplementation((p, opts) => { + if (String(p).endsWith(".staging")) { + throw Object.assign(new Error("resource busy or locked"), { + code: "EBUSY", + }); + } + rmSync(p, opts); + }); + try { + const failed = build(AssetType.DIRECTORY, failing); + expect(() => failed.app.synth()).toThrow(/build failed/); + const hashFolder = path.join(failed.stackDir, failed.asset.path); + expect(fs.existsSync(`${hashFolder}.staging`)).toBe(true); + } finally { + spy.mockRestore(); + } + + const bundler = countingBundler(); + const { asset, stackDir } = synth(AssetType.DIRECTORY, bundler); + expect(bundler.runs).toBe(1); + expect( + fs.readdirSync(path.dirname(path.join(stackDir, asset.path))), + ).toEqual([asset.assetHash, `${asset.assetHash}.json`]); + }); + + test("moving a directory asset's source does not rebuild it", () => { + const bundler = countingBundler(); + const first = synth(AssetType.DIRECTORY, bundler); + const moved = `${srcDir}-moved`; + fs.renameSync(srcDir, moved); + srcDir = moved; + const second = synth(AssetType.DIRECTORY, bundler); + + expect(second.asset.assetHash).toBe(first.asset.assetHash); + expect(bundler.runs).toBe(1); + }); + test("a rename refused with EPERM is retried", () => { const spy = jest.spyOn(fs, "renameSync").mockImplementationOnce(() => { throw Object.assign(new Error("operation not permitted"), {