Skip to content

feat: make the integ test harness work on Windows - #1854

Open
dgandhi62 wants to merge 9 commits into
mainfrom
windows-integ-1-harness
Open

feat: make the integ test harness work on Windows#1854
dgandhi62 wants to merge 9 commits into
mainfrom
windows-integ-1-harness

Conversation

@dgandhi62

@dgandhi62 dgandhi62 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

This pr is created for maintaining separation of concerns from here - #1781

Here, we make modifications to the testing harness, since a lot of the older code was linux-specific and did not run properly on Windows. The code to enable the windows tests will be a follow up.


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license

@dgandhi62 dgandhi62 changed the title feat: make the integ test harness work on Windows feat: make the integ test harness work on Windows (DO NOT REVIEW YET) Aug 19, 2026
@github-actions github-actions Bot added the p2 label Aug 19, 2026
@dgandhi62
dgandhi62 deployed to no-approval August 19, 2026 20:56 — with GitHub Actions Active
@dgandhi62 dgandhi62 changed the title feat: make the integ test harness work on Windows (DO NOT REVIEW YET) feat: make the integ test harness work on Windows Aug 19, 2026
// still. Install every distinct package set only once per machine and
// junction it into the test directory.
const sharedNodeModules = await sharedPackageSetInstall(fixture, packages);
fs.symlinkSync(sharedNodeModules, path.join(fixture.integTestDir, 'node_modules'), 'junction');

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.

Interesting. Lets make use of it on linux as well! We've seen network errors due to multiple npm install processes and I remember also seeing some slow installation in our CodeBuild canary runs.

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.

This was one of the biggest changes for bringing down Windows integ test times, since npm install is so much less efficient. Concur on running it on Linux. @dgandhi62 thanks for the changes, can you show us a workflow run using the latest commit to validate it works as expected (tests pass and shorter test times)?

@dgandhi62 dgandhi62 Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Running it with the latest code now. Will link it here once done

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

await context.library.initializeDotnetPackages(context.integTestDir);
await shell.shell(['cdk', 'synth']);
})));
})), 300_000);

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.

For each instance where we adjust test timeouts, please verify in the workflow run whether it needs this much time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can reduce the timeouts in some to make it closer to the test times. Depending on the urgency of this pr, we can have a separate one to make efficiency improvements in the tests themselves

@dgandhi62 dgandhi62 Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For the ask of updating timeouts based on test times, that is done. If the ask is to identify test improvements to reduce times, that might have to be a separate pr

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.

No ask for the latter. Reducing the timeouts is OK.

@dgandhi62
dgandhi62 force-pushed the windows-integ-1-harness branch from 4e33fc8 to a8f331f Compare August 21, 2026 15:48
@dgandhi62
dgandhi62 deployed to no-approval August 21, 2026 15:48 — with GitHub Actions Active
Comment thread packages/@aws-cdk-testing/cli-integ/lib/shell.ts Outdated
Comment thread packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts Outdated
Comment thread packages/@aws-cdk-testing/cli-integ/lib/shell.ts
Comment thread packages/@aws-cdk-testing/cli-integ/lib/shell.ts Outdated
The integ test harness and several suites assume a POSIX environment, so
they cannot run on a Windows runner. This makes them platform-portable
without changing behaviour on Linux.

- spawn TTY processes through the shell, and widen the ConPTY terminal so
  long prompts are not wrapped before they are matched
- match prompts against ConPTY screen-buffer output
- spawn npm through the node interpreter rather than relying on bin shims
- share one npm install across tests, which dominates runtime on Windows
- fix path handling in the watch tests and search all stage assemblies for
  the nested template
- give the init suites an explicit 5 minute timeout; they previously ran on
  the 60s suite default, which is not enough for Maven, NuGet or Go module
  downloads

No Windows jobs run yet; enabling those is a follow-up.
The shared install added for Windows applies just as well everywhere:
every test asks for the same handful of packages at the same resolved
versions, so installing per test is duplicated work. On Linux that shows
up as many concurrent `npm install` processes, which is a known source of
ECONNRESET failures, and as slow installs in the CodeBuild canary runs.

- drop the win32 gate, and pick the symlink type per platform ('junction'
  on Windows, where a 'dir' symlink needs elevation; ignored on POSIX)
- keep per-test installs when REPO_ROOT rewrites a package to a local
  directory: the cache is keyed on the requested package set, and a
  directory path does not change when its contents are rebuilt, so
  sharing there would serve stale code. No package installed here is
  currently a workspace of this repo, so this is a guard, not a fix
- coordinate through the existing XpMutex instead of a hand-rolled lock
  directory. It reclaims a lock whose owner has died by checking pid
  liveness, rather than waiting out a timeout: previously a worker killed
  mid-install left a lock nothing would release, so every other test on
  the machine waited out the 30 minute deadline and failed

Keying on the package set is safe because `requestedVersion()` always
resolves to an exact version before it reaches the installer.

Addresses review feedback on the shared-install block.
The cross-process mutex guarding the shared npm install represents a lock
as a file: acquire by exclusively creating it, release by unlinking it.
This assumes POSIX deletion semantics, where the only "can't create"
signal is EEXIST and the only "can't read" signal is ENOENT.

Windows differs. A lock file that another process still has open, or that
was just unlinked, enters a "delete pending" state: it lingers in the
directory but open()/read() against it fail with EPERM/EACCES. Under the
heavy startup contention the shared install creates (every jest worker
races for the same lock), tryAcquire() hit EPERM, fell into the
`code !== 'EEXIST'` branch, and rethrew a fatal error. On the Windows
integ runner this took down every test in the suite with an identical
'EPERM: operation not permitted, open ...cdk-integ-shared-install...mutex'.

Treat EPERM/EACCES the same as the POSIX signals: on exclusive create they
mean "held or mid-transition, back off and retry" (like EEXIST); on read
they mean "not readable, treat as gone" (like ENOENT). Add a short sleep
before retrying so a persistent delete-pending window does not busy-spin.

POSIX behavior is unchanged: EPERM does not occur on this path there, so
the new branches are inert on Linux and macOS.

Also bump the init-typescript-app integ test timeouts (300s->600s,
180s->300s) to account for the slower Windows runners.
…changes.

No behavioral change on any platform.

- add an isWindows() util and route the harness's explicit win32 checks
  through it, so platform branches read plainly instead of comparing
  process.platform inline
- in rimraf, replace the try/catch that used a failed unlink to detect a
  Windows junction with an explicit branch: unlink on POSIX, and on Windows
  pick rmdir vs unlink based on whether the link actually resolves to a
  directory (isDirectoryLink) rather than assuming every link is a directory
- reword the rimraf symlink comment to make the link-vs-target boundary
  explicit: we delete this test's private node_modules link and stop, never
  recursing into the shared install other running tests depend on
- inline the single-use RENAME_RETRYABLE array in atomicWrite
// On POSIX, unlink removes a symlink whatever its target type. On
// Windows, a link to a directory (or a junction) must be removed with
// rmdir, while a link to a file must be removed with unlink.
if (isWindows() && isDirectoryLink(fsPath)) {

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 not:

Suggested change
if (isWindows() && isDirectoryLink(fsPath)) {
if (isWindows() && stats.isDirectory()) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We used lstatsync earlier, which describes the symlink, and not the target. isDirectory needs to operate on the target.

Ref - https://www.geeksforgeeks.org/node-js/node-js-fs-lstatsync-method/
https://www.geeksforgeeks.org/node-js/node-js-stats-isdirectory-method-from-fs-stats-class/

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.

Discussed offline, we are already not recursing if its a symlink to a directory, so we just need to handle the unliking correctly:

      // On POSIX, unlink removes a symlink whatever its target type. On
      // Windows, a link to a directory (or a junction) must be removed with
      // rmdir, while a link to a file must be removed with unlink.
      if (stat.isSymbolicLink() && isWindows() && isDirectoryLink(fsPath)) {
        fs.rmdirSync(fsPath);
      } else {
        fs.unlinkSync(fsPath);
      }

* 'node_modules' junction) and those still need `rmdir` on Windows.
*/
function isDirectoryLink(linkPath: string): boolean {
return fs.statSync(linkPath, { throwIfNoEntry: false })?.isDirectory() ?? true;

@iliapolo iliapolo Aug 25, 2026

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.

This function is not needed. But in general - is there a good reason why we would call this on a path that doesn't exist?

Agents will always prefer not to throw - make sure you evaluate that decision every time, because this hides very subtle bugs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I didn't understand - when would the path not exist?

@iliapolo iliapolo Aug 25, 2026

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.

Exactly - it should always exist, which is why we should throw

* On Windows the CLI is an npm .cmd shim, which `spawn` can only start
* through a shell ('spawn cdk ENOENT' otherwise).
*/
export function spawnWatch(args: string[], options: SpawnOptions): ChildProcess {

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 called spawnWatch but actually just runs cdk? Also I'm sure we already have this function somewhere that can be reused.

@dgandhi62 dgandhi62 Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure why (I inherited this code from the original pr). The function is here -

public static spawn(command: string, args: string[], options: child.SpawnOptions = {}): IProcess {

My guess is that we need a way to kill the process at the end specifically for watch, and returning the child process gives us that. The return type of the older function is completely non-destructive.

I think this might explain the naming too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If you look at the integ tests, we've just abstracted away common code. I don't think this is a big change per say

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 was just confused by the name because it doesn't actually spawn cdk watch. Its fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants