-
Notifications
You must be signed in to change notification settings - Fork 485
Serialize test suites sharing build artifacts with a per-checkout lock #8658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cristianoc
wants to merge
1
commit into
master
Choose a base branch
from
cristianoc/test-artifact-lock
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| // @ts-check | ||
|
|
||
| import { spawn } from "node:child_process"; | ||
| import { readFileSync, realpathSync } from "node:fs"; | ||
| import { constants } from "node:os"; | ||
| import { fileURLToPath } from "node:url"; | ||
|
|
||
| const root = realpathSync(fileURLToPath(new URL("../", import.meta.url))); | ||
| const wrapper = fileURLToPath( | ||
| new URL("../scripts/with_test_lock.py", import.meta.url), | ||
| ); | ||
|
|
||
| /** Acquire the checkout lock before a directly invoked runner does any work. */ | ||
| export async function ensureTestLock() { | ||
| // Windows has no flock. CI runs this runner there, so proceed unlocked | ||
| // rather than failing; concurrent suites in one checkout stay unsupported. | ||
| if (process.platform === "win32") return; | ||
| try { | ||
| const owner = JSON.parse(process.env.RESCRIPT_TEST_LOCK ?? "null"); | ||
| const recorded = JSON.parse( | ||
| readFileSync(`${root}/.rescript-test.lock`, "utf8"), | ||
| ); | ||
| if ( | ||
| owner?.root === root && | ||
| owner.pid === recorded.pid && | ||
| owner.token === recorded.token | ||
| ) { | ||
| process.kill(owner.pid, 0); | ||
| return; | ||
| } | ||
| } catch { | ||
| // Missing/stale ownership: acquire through the OS lock, never skip it. | ||
| } | ||
| const child = spawn( | ||
| "python3", | ||
| [ | ||
| wrapper, | ||
| "--label", | ||
| "scripts/test.js", | ||
| "--", | ||
| process.execPath, | ||
| ...process.argv.slice(1), | ||
| ], | ||
| { stdio: "inherit" }, | ||
| ); | ||
| /** @type {NodeJS.Signals[]} */ | ||
| const signals = ["SIGINT", "SIGTERM", "SIGHUP"]; | ||
| const forwards = signals.map(signal => { | ||
| const forward = () => { | ||
| child.kill(signal); | ||
| }; | ||
| process.on(signal, forward); | ||
| return { signal, forward }; | ||
| }); | ||
| let status; | ||
| try { | ||
| status = await new Promise(resolve => { | ||
| child.once("error", error => { | ||
| console.error(`[test-lock] ${error.message}`); | ||
| resolve(1); | ||
| }); | ||
| child.once("exit", (code, signal) => | ||
| resolve(code ?? (signal ? 128 + constants.signals[signal] : 1)), | ||
| ); | ||
| }); | ||
| } finally { | ||
| for (const { signal, forward } of forwards) | ||
| process.removeListener(signal, forward); | ||
| } | ||
| process.exit(status); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| """Process-level tests for the shared-artifact lock prototype.""" | ||
|
|
||
| import json | ||
| import os | ||
| from pathlib import Path | ||
| import selectors | ||
| import signal | ||
| import subprocess | ||
| import sys | ||
| import tempfile | ||
| import unittest | ||
|
|
||
| WRAPPER = Path(__file__).resolve().with_name("with_test_lock.py") | ||
| HOLD = """ | ||
| import pathlib, sys, time | ||
| print('START ' + sys.argv[1], flush=True) | ||
| while not pathlib.Path(sys.argv[2]).exists(): | ||
| time.sleep(0.02) | ||
| print('END ' + sys.argv[1], flush=True) | ||
| """ | ||
|
|
||
|
|
||
| @unittest.skipUnless(os.name == "posix", "prototype uses POSIX flock") | ||
| class TestArtifactLock(unittest.TestCase): | ||
| def setUp(self): | ||
| self.temp = tempfile.TemporaryDirectory(prefix="rescript-lock-test-") | ||
| self.addCleanup(self.temp.cleanup) | ||
| self.root = Path(self.temp.name) | ||
| self.children = [] | ||
| self.addCleanup(self.stop_children) | ||
|
|
||
| def stop_children(self): | ||
| for child in self.children: | ||
| if child.poll() is None: | ||
| child.kill() | ||
| child.wait(timeout=5) | ||
| child.stdout.close() | ||
| child.stderr.close() | ||
|
|
||
| def command(self, root, label, *command): | ||
| return [sys.executable, str(WRAPPER), "--root", str(root), | ||
| "--label", label, "--", *command] | ||
|
|
||
| def start(self, root, label, *command, env=None): | ||
| child = subprocess.Popen(self.command(root, label, *command), | ||
| stdout=subprocess.PIPE, stderr=subprocess.PIPE, | ||
| text=True, env=env) | ||
| self.children.append(child) | ||
| return child | ||
|
|
||
| def line(self, stream): | ||
| with selectors.DefaultSelector() as selector: | ||
| selector.register(stream, selectors.EVENT_READ) | ||
| self.assertTrue(selector.select(timeout=5), "timed out waiting for output") | ||
| return stream.readline().strip() | ||
|
|
||
| def hold(self, root, label): | ||
| release = root / (label + ".release") | ||
| child = self.start(root, label, sys.executable, "-c", HOLD, label, str(release)) | ||
| return child, release | ||
|
|
||
| def test_competing_suites_wait_for_entire_command(self): | ||
| first, release_first = self.hold(self.root, "test") | ||
| self.assertIn("acquired", self.line(first.stderr)) | ||
| self.assertEqual(self.line(first.stdout), "START test") | ||
| second, release_second = self.hold(self.root, "test-analysis") | ||
| self.assertIn("waiting for shared artifacts", self.line(second.stderr)) | ||
| self.assertIsNone(second.poll()) | ||
| release_first.touch() | ||
| self.assertEqual(self.line(first.stdout), "END test") | ||
| self.assertEqual(first.wait(timeout=5), 0) | ||
| self.assertIn("acquired", self.line(second.stderr)) | ||
| self.assertEqual(self.line(second.stdout), "START test-analysis") | ||
| release_second.touch() | ||
| self.assertEqual(self.line(second.stdout), "END test-analysis") | ||
| self.assertEqual(second.wait(timeout=5), 0) | ||
|
|
||
| def test_node_command_retains_the_kernel_lock(self): | ||
| release = self.root / "node.release" | ||
| code = ("console.log('START node'); setInterval(() => {" | ||
| "if (require('node:fs').existsSync(process.argv[1])) process.exit(0);" | ||
| "}, 20)") | ||
| first = self.start(self.root, "node", "node", "--input-type=commonjs", | ||
| "-e", code, str(release)) | ||
| self.assertEqual(self.line(first.stdout), "START node") | ||
| second = self.start(self.root, "next", sys.executable, "-c", "pass") | ||
| self.assertIn("waiting", self.line(second.stderr)) | ||
| release.touch() | ||
| self.assertEqual(first.wait(timeout=5), 0) | ||
| _, err = second.communicate(timeout=5) | ||
| self.assertEqual(second.returncode, 0, err) | ||
|
|
||
| def test_nested_command_does_not_deadlock(self): | ||
| nested = self.command(self.root, "nested", sys.executable, "-c", "print('nested ok')") | ||
| code = "import subprocess,sys; sys.exit(subprocess.call(" + repr(nested) + "))" | ||
| child = self.start(self.root, "outer", sys.executable, "-c", code) | ||
| out, err = child.communicate(timeout=5) | ||
| self.assertEqual(child.returncode, 0, err) | ||
| self.assertEqual(out.strip(), "nested ok") | ||
| self.assertEqual(err.count("acquired"), 1) | ||
|
|
||
| def test_failure_releases_lock_and_preserves_exit_status(self): | ||
| child = self.start(self.root, "fails", sys.executable, "-c", "raise SystemExit(7)") | ||
| child.communicate(timeout=5) | ||
| self.assertEqual(child.returncode, 7) | ||
| successor = self.start(self.root, "next", sys.executable, "-c", "pass") | ||
| _, err = successor.communicate(timeout=5) | ||
| self.assertEqual(successor.returncode, 0, err) | ||
| self.assertNotIn("waiting", err) | ||
|
|
||
| def test_killed_owner_releases_kernel_lock(self): | ||
| first, _ = self.hold(self.root, "killed") | ||
| self.assertIn("acquired", self.line(first.stderr)) | ||
| self.assertEqual(self.line(first.stdout), "START killed") | ||
| second = self.start(self.root, "next", sys.executable, "-c", "pass") | ||
| self.assertIn("waiting", self.line(second.stderr)) | ||
| first.kill() | ||
| self.assertEqual(first.wait(timeout=5), -signal.SIGKILL) | ||
| _, err = second.communicate(timeout=5) | ||
| self.assertEqual(second.returncode, 0, err) | ||
| self.assertIn("acquired", err) | ||
|
|
||
| def test_separate_checkouts_do_not_block_each_other(self): | ||
| first, release_first = self.hold(self.root, "first") | ||
| self.assertEqual(self.line(first.stdout), "START first") | ||
| other_root = self.root / "other" | ||
| other_root.mkdir() | ||
| second, release_second = self.hold(other_root, "second") | ||
| self.assertIn("acquired", self.line(second.stderr)) | ||
| self.assertEqual(self.line(second.stdout), "START second") | ||
| self.assertIsNone(first.poll()) | ||
| release_first.touch() | ||
| release_second.touch() | ||
|
|
||
| def test_missing_flock_fails_instead_of_running_unlocked(self): | ||
| shadow = Path(tempfile.mkdtemp(dir=self.root)) | ||
| (shadow / "fcntl.py").write_text('raise ImportError("no flock here")') | ||
| env = {**os.environ, "PYTHONPATH": str(shadow)} | ||
| child = self.start(self.root, "no-flock", sys.executable, "-c", "print('ran')", | ||
| env=env) | ||
| out, err = child.communicate(timeout=5) | ||
| self.assertEqual(child.returncode, 2, err) | ||
| self.assertNotIn("ran", out) | ||
| self.assertIn("requires POSIX flock", err) | ||
|
|
||
| def test_stale_environment_marker_does_not_skip_lock(self): | ||
| first, release_first = self.hold(self.root, "owner") | ||
| self.assertEqual(self.line(first.stdout), "START owner") | ||
| stale = {"root": str(self.root), "pid": os.getpid(), "token": "old"} | ||
| env = {**os.environ, "RESCRIPT_TEST_LOCK": json.dumps(stale)} | ||
| second = self.start(self.root, "stale", sys.executable, "-c", "pass", env=env) | ||
| self.assertIn("waiting", self.line(second.stderr)) | ||
| release_first.touch() | ||
| _, err = second.communicate(timeout=5) | ||
| self.assertEqual(second.returncode, 0, err) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main(verbosity=2) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a direct
node scripts/test.jsprocess is killed withSIGKILL(or another fatal signal not handled below), only this parent dies; the spawned wrapper/test process remains alive and continues holding the checkout lock. I reproduced this by killing the original Node PID, after which a second lock command waited until the inner PID was manually killed. This can leave tests running unexpectedly and block later suites, so the child must be tied to the parent's lifetime or the persistent intermediary should be avoided.AGENTS.md reference: AGENTS.md:L163-L166
Useful? React with 👍 / 👎.