Skip to content

Commit c736fc5

Browse files
committed
fix: recover stale PostgreSQL cache locks
1 parent 10a8857 commit c736fc5

3 files changed

Lines changed: 218 additions & 11 deletions

File tree

scripts/pg_compat/common.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
1919
PG_VERSION_PATTERN = re.compile(r"^[0-9]+(?:\.[0-9]+)+$")
2020
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
21+
ASCII_CONTROL_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
2122

2223

2324
def _require_nonempty_string(value, name):
@@ -33,6 +34,8 @@ def _validate_version_pin(version, role):
3334

3435
_require_nonempty_string(version["branch"], f"{prefix}.branch")
3536
_require_nonempty_string(version["pg_version"], f"{prefix}.pg_version")
37+
if ASCII_CONTROL_PATTERN.search(version["branch"]):
38+
raise ValueError(f"{prefix}.branch must not contain ASCII control characters")
3639

3740
if not isinstance(version["commit"], str) or not COMMIT_PATTERN.fullmatch(
3841
version["commit"]

scripts/pg_compat/fetch_libpg_query.sh

Lines changed: 129 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ PINS="${PG_COMPAT_PINS:-${ROOT}/tests/pg_compat/upstream_pins.json}"
66
CACHE="${PG_COMPAT_CACHE:-/tmp/parsersql-pg-compat}"
77
WITH_POSTGRES_SOURCE=0
88
LOCK_DIR="${CACHE}/.pg_compat.lock"
9-
LOCK_OWNER="$$-${RANDOM}-${RANDOM}"
9+
LOCK_OWNER_PATTERN=$'^([1-9][0-9]*)\t([A-Za-z0-9][A-Za-z0-9._-]*)\n$'
10+
LOCK_OWNER_RECORD=
11+
LOCK_OWNER_TMP=
12+
LOCK_RECLAIM_DIR=
13+
LOCK_RECLAIM_FILE=
1014
LOCK_HELD=0
15+
FILE_CONTENT=
1116

1217
usage() {
1318
echo "Usage: $0 [--with-postgres-source]" >&2
@@ -27,25 +32,138 @@ fi
2732

2833
cd "$ROOT"
2934

30-
release_lock() {
31-
if [[ "$LOCK_HELD" -ne 1 || ! -d "$LOCK_DIR" ]]; then
35+
read_file_exact() {
36+
local path="$1"
37+
38+
if [[ ! -f "$path" ]]; then
39+
return 1
40+
fi
41+
FILE_CONTENT="$(cat "$path"; printf '\034')" || return 1
42+
FILE_CONTENT="${FILE_CONTENT%$'\034'}"
43+
}
44+
45+
restore_reclaim_owner() {
46+
if [[ -z "$LOCK_RECLAIM_FILE" || ! -f "$LOCK_RECLAIM_FILE" ]]; then
47+
if [[ -n "$LOCK_RECLAIM_DIR" ]]; then
48+
rmdir "$LOCK_RECLAIM_DIR" 2>/dev/null || true
49+
LOCK_RECLAIM_DIR=
50+
LOCK_RECLAIM_FILE=
51+
fi
3252
return
3353
fi
34-
if [[ -f "$LOCK_DIR/owner" ]] && [[ "$(cat "$LOCK_DIR/owner")" == "$LOCK_OWNER" ]]; then
35-
rm -f "$LOCK_DIR/owner"
36-
rmdir "$LOCK_DIR" 2>/dev/null || true
54+
if [[ -d "$LOCK_DIR" && ! -e "$LOCK_DIR/owner" ]]; then
55+
if ln "$LOCK_RECLAIM_FILE" "$LOCK_DIR/owner" 2>/dev/null; then
56+
rm -f "$LOCK_RECLAIM_FILE"
57+
rmdir "$LOCK_RECLAIM_DIR" 2>/dev/null || true
58+
LOCK_RECLAIM_DIR=
59+
LOCK_RECLAIM_FILE=
60+
fi
61+
return
62+
fi
63+
rm -f "$LOCK_RECLAIM_FILE"
64+
rmdir "$LOCK_RECLAIM_DIR" 2>/dev/null || true
65+
LOCK_RECLAIM_DIR=
66+
LOCK_RECLAIM_FILE=
67+
}
68+
69+
release_lock() {
70+
restore_reclaim_owner
71+
if [[ "$LOCK_HELD" -eq 1 && -d "$LOCK_DIR" ]]; then
72+
if [[ ! -e "$LOCK_DIR/owner" ]]; then
73+
rmdir "$LOCK_DIR" 2>/dev/null || true
74+
elif read_file_exact "$LOCK_DIR/owner" &&
75+
[[ "$FILE_CONTENT" == "$LOCK_OWNER_RECORD" ]]; then
76+
rm -f "$LOCK_DIR/owner"
77+
rmdir "$LOCK_DIR" 2>/dev/null || true
78+
fi
79+
fi
80+
if [[ -n "$LOCK_OWNER_TMP" ]]; then
81+
rm -f "$LOCK_OWNER_TMP"
82+
LOCK_OWNER_TMP=
3783
fi
3884
LOCK_HELD=0
3985
}
4086

87+
reclaim_stale_lock() {
88+
local initial_owner
89+
local owner_pid
90+
local reclaim_dir
91+
92+
if ! read_file_exact "$LOCK_DIR/owner"; then
93+
return 1
94+
fi
95+
initial_owner="$FILE_CONTENT"
96+
if [[ ! "$initial_owner" =~ $LOCK_OWNER_PATTERN ]]; then
97+
return 1
98+
fi
99+
owner_pid="${BASH_REMATCH[1]}"
100+
if kill -0 "$owner_pid" 2>/dev/null; then
101+
return 1
102+
fi
103+
if ! read_file_exact "$LOCK_DIR/owner" ||
104+
[[ "$FILE_CONTENT" != "$initial_owner" ]]; then
105+
return 1
106+
fi
107+
108+
if ! reclaim_dir="$(mktemp -d "${CACHE}/.pg_compat.reclaim.XXXXXX")"; then
109+
return 1
110+
fi
111+
LOCK_RECLAIM_DIR="$reclaim_dir"
112+
LOCK_RECLAIM_FILE="${LOCK_RECLAIM_DIR}/owner"
113+
if ! mv "$LOCK_DIR/owner" "$LOCK_RECLAIM_FILE" 2>/dev/null; then
114+
rmdir "$LOCK_RECLAIM_DIR" 2>/dev/null || true
115+
LOCK_RECLAIM_DIR=
116+
LOCK_RECLAIM_FILE=
117+
return 1
118+
fi
119+
if ! read_file_exact "$LOCK_RECLAIM_FILE" ||
120+
[[ "$FILE_CONTENT" != "$initial_owner" ]]; then
121+
restore_reclaim_owner
122+
return 1
123+
fi
124+
if ! rmdir "$LOCK_DIR" 2>/dev/null; then
125+
restore_reclaim_owner
126+
return 1
127+
fi
128+
129+
rm -f "$LOCK_RECLAIM_FILE"
130+
rmdir "$LOCK_RECLAIM_DIR" 2>/dev/null || true
131+
LOCK_RECLAIM_DIR=
132+
LOCK_RECLAIM_FILE=
133+
}
134+
135+
publish_lock() {
136+
if ! mv "$LOCK_OWNER_TMP" "$LOCK_DIR/owner"; then
137+
echo "Unable to publish PostgreSQL compatibility cache lock owner" >&2
138+
exit 1
139+
fi
140+
LOCK_OWNER_TMP=
141+
}
142+
41143
acquire_lock() {
144+
local lock_token
145+
42146
mkdir -p "$CACHE"
43-
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
44-
echo "PostgreSQL compatibility cache is locked: ${CACHE}" >&2
45-
exit 1
147+
LOCK_OWNER_TMP="$(mktemp "${CACHE}/.pg_compat.owner.XXXXXX")"
148+
lock_token="${LOCK_OWNER_TMP##*.pg_compat.owner.}"
149+
LOCK_OWNER_RECORD="$$"$'\t'"${lock_token}"$'\n'
150+
printf '%s' "$LOCK_OWNER_RECORD" > "$LOCK_OWNER_TMP"
151+
152+
if mkdir "$LOCK_DIR" 2>/dev/null; then
153+
LOCK_HELD=1
154+
publish_lock
155+
return
156+
fi
157+
if reclaim_stale_lock && mkdir "$LOCK_DIR" 2>/dev/null; then
158+
LOCK_HELD=1
159+
publish_lock
160+
return
46161
fi
47-
printf '%s\n' "$LOCK_OWNER" > "$LOCK_DIR/owner"
48-
LOCK_HELD=1
162+
163+
rm -f "$LOCK_OWNER_TMP"
164+
LOCK_OWNER_TMP=
165+
echo "PostgreSQL compatibility cache is locked: ${CACHE}" >&2
166+
exit 1
49167
}
50168

51169
trap release_lock EXIT

tests/pg_compat/test_common.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,24 @@ def test_rejects_malformed_pin_values(self):
128128
):
129129
load_pins(path)
130130

131+
def test_rejects_ascii_control_characters_in_branch(self):
132+
with tempfile.TemporaryDirectory() as directory:
133+
for codepoint in (*range(0x20), 0x7F):
134+
with self.subTest(codepoint=codepoint):
135+
pins = valid_pins()
136+
pins["versions"]["previous"]["branch"] = (
137+
f"17-{chr(codepoint)}latest"
138+
)
139+
path = self.write_pins(
140+
Path(directory) / str(codepoint), pins
141+
)
142+
143+
with self.assertRaisesRegex(
144+
ValueError,
145+
r"versions\.previous\.branch.*ASCII control",
146+
):
147+
load_pins(path)
148+
131149

132150
class ParseMakefileVersionTest(unittest.TestCase):
133151
def test_parses_pg_version_and_numeric_version(self):
@@ -310,6 +328,13 @@ def run_fetch(self, environment, *arguments, timeout=10):
310328
timeout=timeout,
311329
)
312330

331+
def create_lock(self, cache, owner=None):
332+
lock_dir = cache / ".pg_compat.lock"
333+
lock_dir.mkdir(parents=True)
334+
if owner is not None:
335+
(lock_dir / "owner").write_text(owner, encoding="utf-8")
336+
return lock_dir
337+
313338
def test_bad_download_is_not_published_and_poisoned_cache_recovers(self):
314339
good_content = b"good archive"
315340
expected_hash = hashlib.sha256(good_content).hexdigest()
@@ -392,6 +417,67 @@ def test_concurrent_fetch_is_rejected_without_mutating_cache(self):
392417
)
393418
self.assertFalse((cache / ".pg_compat.lock").exists())
394419

420+
def test_valid_dead_pid_lock_is_recovered(self):
421+
with tempfile.TemporaryDirectory() as directory:
422+
_, cache, _, environment = self.create_harness(
423+
directory, valid_pins()
424+
)
425+
dead_process = subprocess.Popen(["true"])
426+
dead_process.wait(timeout=5)
427+
self.create_lock(cache, f"{dead_process.pid}\tstale-token\n")
428+
429+
result = self.run_fetch(environment)
430+
431+
self.assertEqual(result.returncode, 0, result.stderr)
432+
self.assertFalse((cache / ".pg_compat.lock").exists())
433+
434+
def test_live_pid_lock_is_rejected_without_reclamation(self):
435+
with tempfile.TemporaryDirectory() as directory:
436+
root, cache, _, environment = self.create_harness(
437+
directory, valid_pins()
438+
)
439+
owner = f"{os.getpid()}\tlive-token\n"
440+
lock_dir = self.create_lock(cache, owner)
441+
git_called = root / "git-called"
442+
environment["STUB_GIT_CALLED"] = str(git_called)
443+
444+
result = self.run_fetch(environment)
445+
446+
self.assertNotEqual(result.returncode, 0)
447+
self.assertIn("cache is locked", result.stderr)
448+
self.assertEqual(
449+
(lock_dir / "owner").read_text(encoding="utf-8"), owner
450+
)
451+
self.assertFalse(git_called.exists())
452+
453+
def test_malformed_or_missing_owner_lock_is_rejected_without_reclamation(self):
454+
cases = (None, "not-a-valid-owner\n")
455+
with tempfile.TemporaryDirectory() as directory:
456+
for index, owner in enumerate(cases):
457+
with self.subTest(owner=owner):
458+
harness_root = Path(directory) / str(index)
459+
harness_root.mkdir()
460+
root, cache, _, environment = self.create_harness(
461+
harness_root, valid_pins()
462+
)
463+
lock_dir = self.create_lock(cache, owner)
464+
git_called = root / "git-called"
465+
environment["STUB_GIT_CALLED"] = str(git_called)
466+
467+
result = self.run_fetch(environment)
468+
469+
self.assertNotEqual(result.returncode, 0)
470+
self.assertIn("cache is locked", result.stderr)
471+
self.assertTrue(lock_dir.is_dir())
472+
if owner is None:
473+
self.assertFalse((lock_dir / "owner").exists())
474+
else:
475+
self.assertEqual(
476+
(lock_dir / "owner").read_text(encoding="utf-8"),
477+
owner,
478+
)
479+
self.assertFalse(git_called.exists())
480+
395481
def test_terminated_fetch_releases_lock_without_continuing(self):
396482
with tempfile.TemporaryDirectory() as directory:
397483
root, cache, _, environment = self.create_harness(

0 commit comments

Comments
 (0)