Skip to content

Make E2E test server port and cleanup safe for parallel runs - #1420

Merged
Tim020 merged 2 commits into
devfrom
feature/e2e-parallel-isolation
Sep 24, 2026
Merged

Tim020 merged 2 commits into
devfrom
feature/e2e-parallel-isolation

Conversation

@Tim020

@Tim020 Tim020 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Problem

Two E2E suites running at once (e.g. separate git worktrees driven by parallel CI/agent jobs) could not coexist:

  • SERVER_PORT was hardcoded to 8888 in three places (playwright.config.ts, e2e/global-setup.ts, e2e/helpers.ts).
  • global-setup.ts's stale-port cleanup SIGKILLed whatever was listening on the port (via lsof) — a second run would destroy the first run's backend mid-suite.
  • PID/tempdir pointers lived in fixed filenames in os.tmpdir(), so a second run overwrote the first run's pointers, causing its teardown to kill the wrong server and delete the wrong database.
  • Only db_path/log_path were isolated into the per-run temp dir; compiled_script_path, draft_script_path, client_log_path and db_log_path defaulted into server/conf/ of the checkout.

Fix

All changes are in client-v3/:

  • New e2e/env.ts is the single source for SERVER_PORT/BASE_URL/RUN_DIR/STATE_FILE, imported by playwright.config.ts, global-setup.ts, global-teardown.ts, helpers.ts and db-snapshot.ts.
    • Port resolution: E2E_PORT env var if set, else a port deterministically derived from process.cwd() (range 20000–39999). Each checkout gets its own stable port with no cross-process coordination needed, and E2E_PORT remains available to pin a specific port (e.g. to match CI locally).
  • global-setup.ts no longer kills whatever holds the port. It only reaps a process it can positively identify as its own leftover, by matching ps output against main.py ... --port=<port> directly — not solely via the PID recorded in state.json (a real race was found empirically between this and db-snapshot.ts's retry-restart hook, see below). If the port is genuinely held by something else, setup fails fast with a message pointing at E2E_PORT instead of touching it.
  • State moves from fixed tmpdir files to a per-port state.json under os.tmpdir()/digiscript-e2e-<port>/, so two runs can never clobber each other's pointers.
  • Full path isolation: the generated settings JSON now also isolates compiled_script_path, draft_script_path, client_log_path and db_log_path into the run's temp dir.
  • Server stdout/stderr now goes to a log file that's surfaced on a setup failure, instead of being discarded (stdio: 'ignore').

Verification

  • Concurrency proof (the real test): ran the full suite from two separate directories simultaneously — 221/221 passed in each, confirmed via lsof that two distinct derived ports were live at the same time, and a third, unrelated server already running on the old fixed port 8888 (from an unrelated parallel job) was left completely undisturbed throughout.
  • Foreign-listener safety: started a real listener on the derived port, confirmed setup fails fast with the intended message and leaves that process alone. (Caught and fixed a real bug in the process — the port-free probe bound 127.0.0.1 specifically, while Tornado's app.listen(port) binds all interfaces, which could give a false "port free" result on some stacks.)
  • Stale-recovery, both failure shapes:
    • kill -9 of the Playwright runner mid-suite, leaving state.json + a live server → reaped cleanly on next run.
    • A live leftover server with no state.json at all (the retry/teardown race mentioned above) → also reaped cleanly on next run, via the process-identity match.
  • npm run ci-lint / npm run typecheck clean.
  • Confirmed no files are written under server/conf/ by the E2E harness.

Design + verification detail: plans/parallel-e2e-isolation.md (gitignored, not included in this PR).

🤖 Generated with Claude Code

Two E2E suites running at once (e.g. separate worktrees driven by
parallel agent jobs) could not coexist:

- SERVER_PORT was hardcoded to 8888 in three places.
- global-setup.ts's stale-port cleanup SIGKILLed whatever was
  listening on the port via lsof, destroying a second run's backend.
- PID/tempdir pointers lived in fixed files in os.tmpdir(), so a
  second run overwrote the first run's pointers.
- Only db_path/log_path were isolated per run; compiled/draft script
  paths and the two remaining log paths defaulted into server/conf/.

Fix, all in client-v3/:

- New e2e/env.ts is the single source for SERVER_PORT/BASE_URL/
  RUN_DIR/STATE_FILE, imported by config, setup, teardown, helpers,
  and db-snapshot. Port resolution: E2E_PORT env var if set, else a
  port deterministically derived from process.cwd() (20000-39999),
  so each checkout gets its own stable port with no cross-process
  coordination needed.
- global-setup.ts no longer kills whatever holds the port. It only
  reaps a process it can positively identify as its own leftover, by
  matching `ps` output against `main.py ... --port=<port>` directly
  (not solely via the PID recorded in state.json, which a crashed
  run's teardown can delete without having killed the live process -
  found via a real race against db-snapshot.ts's retry-restart hook).
  If the port is genuinely held by something else, setup fails fast
  with a message pointing at E2E_PORT instead of touching it.
- State (PID + tempDir) moves from fixed tmpdir files to a per-port
  state.json under os.tmpdir()/digiscript-e2e-<port>/, so two runs
  can never clobber each other's pointers.
- The generated settings JSON now isolates compiled_script_path,
  draft_script_path, client_log_path and db_log_path into the run's
  tempDir too, alongside the existing db_path/log_path.
- Server stdout/stderr now goes to a log file surfaced on setup
  failure, instead of being discarded.

Verified: two full suites run concurrently from separate directories,
221/221 passed each, confirmed via lsof that distinct ports were live
simultaneously, and an unrelated server already on the old fixed port
8888 was left undisturbed throughout. Also verified: a genuine foreign
listener on the derived port is refused (not killed); a stale server
left behind by a killed runner is reaped, both with and without a
matching state.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Tim020 Tim020 added the claude Issues created by Claude label Sep 22, 2026
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Client V3 Test Results

209 tests   209 ✅  0s ⏱️
 14 suites    0 💤
  1 files      0 ❌

Results for commit e5a6224.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Client Test Results

95 tests   95 ✅  0s ⏱️
 6 suites   0 💤
 1 files     0 ❌

Results for commit e5a6224.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Python Test Results

  1 files    1 suites   47s ⏱️
934 tests 934 ✅ 0 💤 0 ❌
939 runs  939 ✅ 0 💤 0 ❌

Results for commit e5a6224.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Playwright E2E Results (chromium)

221 tests   221 ✅  2m 17s ⏱️
 14 suites    0 💤
  1 files      0 ❌

Results for commit e5a6224.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Playwright E2E Results (firefox)

221 tests   221 ✅  2m 12s ⏱️
 14 suites    0 💤
  1 files      0 ❌

Results for commit e5a6224.

♻️ This comment has been updated with latest results.

Fixes all 9 issues SonarCloud flagged as new code on PR #1420
(client-v3/e2e/env.ts, global-setup.ts), which had failed the PR's
Quality Gate on Security Rating:

- typescript:S4790 (CRITICAL/security): env.ts derived the E2E port
  using SHA-1, flagged as a weak hash algorithm. Not actually a
  sensitive use (just spreading a cwd path into a port number), but
  switching to SHA-256 removes the finding for free with no behaviour
  change (still deterministic, still fine for port selection).
- typescript:S4036 (security): global-setup.ts's stale-server reap
  invoked `ps` by bare name, resolved via $PATH. Now resolved from a
  fixed list of unwriteable absolute paths (/bin/ps, /usr/bin/ps),
  same behaviour on macOS/Linux, no PATH-hijack surface.
- typescript:S7772 (x5): prefer `node:` protocol-prefixed imports for
  builtins (crypto/os/path/net/fs/child_process). Applied consistently
  across all touched e2e/ files, not just the flagged lines.
- typescript:S7773 (x4): prefer `Number.parseInt`/`Number.isNaN` over
  the global `parseInt`/`isNaN`.

Re-verified after these changes: full E2E suite green (221/221), and
specifically re-ran the stale-orphan-server reap check (a server with
no matching state.json) against the *new* SHA-256-derived port to
confirm the /bin/ps-based matching still reaps it correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@Tim020
Tim020 merged commit 318e4bf into dev Sep 24, 2026
30 checks passed
@Tim020
Tim020 deleted the feature/e2e-parallel-isolation branch September 24, 2026 00:02
Tim020 added a commit that referenced this pull request Sep 24, 2026
Replaces npm with pnpm 12.5.1 as the package manager for both `client/`
and `client-v3/` (each keeps its own independent pnpm-lock.yaml — no
shared workspace). This directly addresses the recurring npm lockfile
problems recorded against this repo: npm only records the optional
platform/CPU package variants it actually installed locally (npm/cli
#7961/#8320), so a lockfile regenerated on macOS silently drops the
Linux CI variants, and separately, targeted `npm install` runs have
been observed to non-deterministically drop unrelated optional
dependency entries. pnpm resolves from registry metadata rather than
local `node_modules`, so the lockfile is platform-independent by
construction — verified by spot-checking the generated pnpm-lock.yaml
for @rolldown/binding-* entries across all target platforms, not just
darwin-arm64 (the machine that generated it).

Electron is gone (#1416), which removes the one blocker to a clean pnpm
migration: Electron Forge needs pnpm's `node-linker=hoisted`, giving up
its strict node_modules; with Electron gone, both remaining projects use
pnpm's default strict linker.

- `packageManager: "pnpm@12.5.1"` pinned in both package.json files;
  `engines.npm` removed (meaningless under pnpm).
- `pnpm-workspace.yaml` added per project (pnpm 10+ moved `overrides`
  and `onlyBuiltDependencies` out of package.json's `pnpm` field into
  this file). Each allowlists the install/postinstall scripts actually
  needed: `@parcel/watcher` (native file-watcher binding used by
  chokidar for HMR/watch mode), `vue-demi` (client-v3 only — selects
  the Vue 2 vs 3 interop shim, functionally required), `esbuild`
  (client only — downloads its native binary, required to run at all),
  `bootstrap-vue`/`core-js` (both harmless donation-message no-ops,
  allowed for completeness).
- `client/.npmrc`: dropped `legacy-peer-deps=true` (npm-specific, no
  pnpm equivalent needed — pnpm warns rather than hard-fails on the
  pre-existing `@vue/test-utils@2.5.1` peer mismatch against vue@2.7.14;
  `pnpm peers check` shows the warning, but it's cosmetic: the full
  test suite already exercises this pairing and passes).
- CI (`nodelint.yml`, `client-test.yml`, `playwright-test.yml`,
  `build-server.yml`) and the two Dockerfile frontend build stages
  switched from `actions/setup-node` + `npm ci` to
  `pnpm/action-setup@v4` + `actions/setup-node` (cache: pnpm) +
  `pnpm install --frozen-lockfile`. `client-test.yml` also picked up a
  pre-existing actions/checkout@v3 → v4 bump for consistency with the
  rest of the CI (unrelated drift, fixed while touching the file).
  `dependabot.yml` needs no change: its `npm` ecosystem key already
  covers pnpm-lock.yaml.
- `scripts/update-version.sh`, `README.md`, `client-v3/README.md` and
  `documentation/development.md` updated to reference pnpm instead of
  npm. Also fixed two pre-existing doc inaccuracies noticed while
  editing: the root README's "Client" build instructions pointed at
  `client` while describing client-v3's output path, and
  `development.md`'s "Project Structure" section had dropped `client-v3`
  entirely (introduced by the Electron-removal pass in #1416, which
  turned "three main components: server/client/electron" into "two:
  server/client" without noticing client-v3 was the actual third).

Testing (both projects): typecheck, ci-lint, full unit test suite, and
production build all pass under pnpm. The full 221-test Playwright
suite against client-v3 also passes cleanly under pnpm (confirmed with
a temporary, since-reverted port/tmpfile override to rule out
interference from an unrelated concurrent E2E run on this machine —
PR #1420 will fix that class of collision generally, but it hasn't
merged yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tim020 added a commit that referenced this pull request Sep 24, 2026
Replaces npm with pnpm 12.5.1 as the package manager for both `client/`
and `client-v3/` (each keeps its own independent pnpm-lock.yaml — no
shared workspace). This directly addresses the recurring npm lockfile
problems recorded against this repo: npm only records the optional
platform/CPU package variants it actually installed locally (npm/cli
#7961/#8320), so a lockfile regenerated on macOS silently drops the
Linux CI variants, and separately, targeted `npm install` runs have
been observed to non-deterministically drop unrelated optional
dependency entries. pnpm resolves from registry metadata rather than
local `node_modules`, so the lockfile is platform-independent by
construction — verified by spot-checking the generated pnpm-lock.yaml
for @rolldown/binding-* entries across all target platforms, not just
darwin-arm64 (the machine that generated it).

Electron is gone (#1416), which removes the one blocker to a clean pnpm
migration: Electron Forge needs pnpm's `node-linker=hoisted`, giving up
its strict node_modules; with Electron gone, both remaining projects use
pnpm's default strict linker.

- `packageManager: "pnpm@12.5.1"` pinned in both package.json files;
  `engines.npm` removed (meaningless under pnpm).
- `pnpm-workspace.yaml` added per project (pnpm 10+ moved `overrides`
  and `onlyBuiltDependencies` out of package.json's `pnpm` field into
  this file). Each allowlists the install/postinstall scripts actually
  needed: `@parcel/watcher` (native file-watcher binding used by
  chokidar for HMR/watch mode), `vue-demi` (client-v3 only — selects
  the Vue 2 vs 3 interop shim, functionally required), `esbuild`
  (client only — downloads its native binary, required to run at all),
  `bootstrap-vue`/`core-js` (both harmless donation-message no-ops,
  allowed for completeness).
- `client/.npmrc`: dropped `legacy-peer-deps=true` (npm-specific, no
  pnpm equivalent needed — pnpm warns rather than hard-fails on the
  pre-existing `@vue/test-utils@2.5.1` peer mismatch against vue@2.7.14;
  `pnpm peers check` shows the warning, but it's cosmetic: the full
  test suite already exercises this pairing and passes).
- CI (`nodelint.yml`, `client-test.yml`, `playwright-test.yml`,
  `build-server.yml`) and the two Dockerfile frontend build stages
  switched from `actions/setup-node` + `npm ci` to
  `pnpm/action-setup@v4` + `actions/setup-node` (cache: pnpm) +
  `pnpm install --frozen-lockfile`. `client-test.yml` also picked up a
  pre-existing actions/checkout@v3 → v4 bump for consistency with the
  rest of the CI (unrelated drift, fixed while touching the file).
  `dependabot.yml` needs no change: its `npm` ecosystem key already
  covers pnpm-lock.yaml.
- `scripts/update-version.sh`, `README.md`, `client-v3/README.md` and
  `documentation/development.md` updated to reference pnpm instead of
  npm. Also fixed two pre-existing doc inaccuracies noticed while
  editing: the root README's "Client" build instructions pointed at
  `client` while describing client-v3's output path, and
  `development.md`'s "Project Structure" section had dropped `client-v3`
  entirely (introduced by the Electron-removal pass in #1416, which
  turned "three main components: server/client/electron" into "two:
  server/client" without noticing client-v3 was the actual third).

Testing (both projects): typecheck, ci-lint, full unit test suite, and
production build all pass under pnpm. The full 221-test Playwright
suite against client-v3 also passes cleanly under pnpm (confirmed with
a temporary, since-reverted port/tmpfile override to rule out
interference from an unrelated concurrent E2E run on this machine —
PR #1420 will fix that class of collision generally, but it hasn't
merged yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tim020 added a commit that referenced this pull request Sep 25, 2026
* Migrate client and client-v3 from npm to pnpm

Replaces npm with pnpm 12.5.1 as the package manager for both `client/`
and `client-v3/` (each keeps its own independent pnpm-lock.yaml — no
shared workspace). This directly addresses the recurring npm lockfile
problems recorded against this repo: npm only records the optional
platform/CPU package variants it actually installed locally (npm/cli
#7961/#8320), so a lockfile regenerated on macOS silently drops the
Linux CI variants, and separately, targeted `npm install` runs have
been observed to non-deterministically drop unrelated optional
dependency entries. pnpm resolves from registry metadata rather than
local `node_modules`, so the lockfile is platform-independent by
construction — verified by spot-checking the generated pnpm-lock.yaml
for @rolldown/binding-* entries across all target platforms, not just
darwin-arm64 (the machine that generated it).

Electron is gone (#1416), which removes the one blocker to a clean pnpm
migration: Electron Forge needs pnpm's `node-linker=hoisted`, giving up
its strict node_modules; with Electron gone, both remaining projects use
pnpm's default strict linker.

- `packageManager: "pnpm@12.5.1"` pinned in both package.json files;
  `engines.npm` removed (meaningless under pnpm).
- `pnpm-workspace.yaml` added per project (pnpm 10+ moved `overrides`
  and `onlyBuiltDependencies` out of package.json's `pnpm` field into
  this file). Each allowlists the install/postinstall scripts actually
  needed: `@parcel/watcher` (native file-watcher binding used by
  chokidar for HMR/watch mode), `vue-demi` (client-v3 only — selects
  the Vue 2 vs 3 interop shim, functionally required), `esbuild`
  (client only — downloads its native binary, required to run at all),
  `bootstrap-vue`/`core-js` (both harmless donation-message no-ops,
  allowed for completeness).
- `client/.npmrc`: dropped `legacy-peer-deps=true` (npm-specific, no
  pnpm equivalent needed — pnpm warns rather than hard-fails on the
  pre-existing `@vue/test-utils@2.5.1` peer mismatch against vue@2.7.14;
  `pnpm peers check` shows the warning, but it's cosmetic: the full
  test suite already exercises this pairing and passes).
- CI (`nodelint.yml`, `client-test.yml`, `playwright-test.yml`,
  `build-server.yml`) and the two Dockerfile frontend build stages
  switched from `actions/setup-node` + `npm ci` to
  `pnpm/action-setup@v4` + `actions/setup-node` (cache: pnpm) +
  `pnpm install --frozen-lockfile`. `client-test.yml` also picked up a
  pre-existing actions/checkout@v3 → v4 bump for consistency with the
  rest of the CI (unrelated drift, fixed while touching the file).
  `dependabot.yml` needs no change: its `npm` ecosystem key already
  covers pnpm-lock.yaml.
- `scripts/update-version.sh`, `README.md`, `client-v3/README.md` and
  `documentation/development.md` updated to reference pnpm instead of
  npm. Also fixed two pre-existing doc inaccuracies noticed while
  editing: the root README's "Client" build instructions pointed at
  `client` while describing client-v3's output path, and
  `development.md`'s "Project Structure" section had dropped `client-v3`
  entirely (introduced by the Electron-removal pass in #1416, which
  turned "three main components: server/client/electron" into "two:
  server/client" without noticing client-v3 was the actual third).

Testing (both projects): typecheck, ci-lint, full unit test suite, and
production build all pass under pnpm. The full 221-test Playwright
suite against client-v3 also passes cleanly under pnpm (confirmed with
a temporary, since-reverted port/tmpfile override to rule out
interference from an unrelated concurrent E2E run on this machine —
PR #1420 will fix that class of collision generally, but it hasn't
merged yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix CI: pin pnpm/action-setup by SHA, pass explicit package_json_file

Every job broke with 'No pnpm version is specified' — pnpm/action-setup
resolves package_json_file relative to GITHUB_WORKSPACE (repo root) by
default, not the job's defaults.run.working-directory (that setting only
applies to run: steps, not uses: steps), and there's no root package.json.
Pass package_json_file explicitly per job.

Also fixes the SonarCloud Quality Gate failure (C Security Rating on New
Code, githubactions:S7637 x8): pin pnpm/action-setup to the exact commit
the v4 tag currently resolves to, rather than a mutable major-version tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Address pnpm migration review round 1

Each finding was reproduced before fixing.

- scripts/update-version.sh: pnpm 12's `pnpm version` is not a passthrough
  to `npm version`; it has a clean-working-tree check, so the second bump
  failed (ERR_PNPM_UNCLEAN_WORKING_TREE) and left a half-applied version
  bump. Add --no-git-checks. Verified end to end on a dirty tree.
- Engine enforcement: pnpm 12 silently ignores `engine-strict` in
  .npmrc (an install with an impossible dependency engine succeeded).
  Set `engineStrict: true` in both pnpm-workspace.yaml files, delete both
  .npmrc files and the Dockerfile COPY for client/.npmrc. Verified it now
  rejects Node 24.12.0 for nopt@10.0.1, as npm's engine-strict used to.
- pnpm-workspace.yaml: drop `onlyBuiltDependencies` (a no-op in pnpm 12;
  only `allowBuilds` counts) and set core-js/bootstrap-vue to false
  (their postinstalls only print donation messages).
- Dockerfile: plain `corepack enable`; the version comes from
  `packageManager` only. Built both frontend stages locally and confirmed
  `pnpm --version` is 12.5.1 in each.
- Add devEngines.packageManager (pnpm, onFail: error) to both
  package.json files so `npm install` is refused (EBADDEVENGINES) and
  cannot create a package-lock.json; ignore package-lock.json too. This
  also makes any `npm run` inside a script fail, so convert the
  lint/ci-lint scripts and eslint-filter.sh to pnpm.
- Docs: add `corepack enable` guidance (Node 24 ships corepack switched
  off) to README.md, client/README.md, client-v3/README.md and
  documentation/development.md; convert remaining npm/npx references in
  client/README.md, client-v3/e2e/README.md and code comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Raise engines.node floor to the real minimum (24.15.0)

Review round 2 suggestion. With engineStrict now working, the declared
`>=24.0.0` was lower than what the install needs: abbrev@5.0.0 and
nopt@10.0.1 (in both lockfiles) require ^24.15.0 on the 24 line, the
highest floor of any dependency.

Note: pnpm 12 does not enforce the root project's own `engines`, even
with engineStrict (checked with an impossible `>=99`), so this is
accurate documentation rather than a friendlier failure; someone on
Node 24.12 still gets the transitive-dependency error.

Also update "Node 24.x" in the READMEs and development docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude Issues created by Claude client-v3 medium-diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant