Skip to content

test: hardening gains its pytest twin, and a crash no longer reads as a clean error (#1341) - #1535

Merged
jdatcmd merged 3 commits into
mainfrom
test/1341-hardening-twin
Oct 19, 2026
Merged

jdatcmd merged 3 commits into
mainfrom
test/1341-hardening-twin

Conversation

@OffgridwithJD

@OffgridwithJD OffgridwithJD commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

For #1341, the unported suite hardening (no twin, no ledger rows).

What changes

A crash no longer reads as a clean error. "invalid descriptor raises error" was [ -z "$badresult" ]. An empty result is also what a crash gives, and what a missing table gives. The arm now reads the SQLSTATE, and wants XX001.

A crash fails its own arms only. "Backend alive" was a bare SELECT 1, which passes once recovery finishes, so it caught a crash only by landing inside recovery. Survival now also counts terminated by signal in the server log since the corruption, as native_varlena_bound does (#1524). Each survival check waits recovery out, so the bloom section starts on a server that answers. Before this, a crash in the descriptor section failed the bloom section's make_pair, and both bloom arms went red for that.

Each corruption must land. Two new premises require the descriptor UPDATE and the bloom UPDATE to report UPDATE 1. Otherwise the arms after them would read untouched metadata and pass.

The corrupted bytes are unchanged. '\\xffffffff' in a standard string is the ASCII text \xffffffff, which bytea's escape format stores as written, and the twin writes the same.

The twin (test_hardening.py) covers all 7 checks under the shell's names, one check per test. It runs on a private cluster, because a crash drops every session, and gives each reading a connection of its own opened after any recovery. Failed readings are query_error()s, and the heap/columnar comparisons use expect.row_set, the port of diff_query. compare_to_bash: 7 literal matches, 0 missing. The suite moves from unported_suites.txt to COMPLETE.

Evidence

Each mutant was built clean. Red sets come from the twin as shipped, compared by name with the shell's FAIL lines.

mutant PG17 shell / twin PG18 shell / twin
none 7/7 / 7 passed same
columnar_reader.c: the unrecognized-descriptor refusal becomes abort(), with the two value-stream bounds in pgcolumnar_chunk_value_bytes off, so it is the one reached 2 red, identical: "invalid descriptor raises error", "backend alive after invalid descriptor" same 2
columnar_bloom.c: a filter too short for its header returns "definitely absent" 1 red, identical: "corrupt bloom present" same 1
the validity-bitmap bound off 0, identical same
both value-stream bounds off 0, identical (the descriptor's own check refuses with the same XX001) same

Clean on PG15, PG16 and PG19: 7/7 and 7 passed.

The old suite, on PG17, under the abort() mutant, 3 runs: "invalid descriptor raises error" PASS every time; "backend alive after invalid descriptor" FAIL; both bloom arms FAIL (QUERY_ERROR.2 vs .1), because the crash took their setup with it.

These corruptions have three layers of refusal in front of them: the validity bound, the 4 GB value-stream cap, and the descriptor check. Each one left in place refuses with XX001, so removing one at a time rightly reddens nothing. The arm checks for a clean refusal, not which layer gave it.

Ledger

7 rows: 3 dated by the two mutations, 4 never:

  • the two premises;
  • "corrupt bloom absent" (424242424 is outside k's range, so a bloom read as absent changes no answer);
  • "backend alive after corrupt bloom".

Bookkeeping, over main 1c7a97a3 (after #1531):

🤖 Generated with Claude Code

@OffgridwithJD
OffgridwithJD force-pushed the test/1341-hardening-twin branch from eb8321f to c6029dd Compare October 17, 2026 21:07

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@OffgridwithJD Requesting changes at eb8321fe. There is one finding, the crash count, and it is small to fix. Everything else holds.

What holds. I wrote both of your mutants myself and ran them on PG17, each built clean. Red sets were compared by name, counting the shell's empty-side form and the twin's VacuityError forms:

mutant shell twin same?
none 7/7 7 passed —
descriptor refusal → abort(), both value-stream bounds in pgcolumnar_chunk_value_bytes off 2: "invalid descriptor raises error", "backend alive after invalid descriptor" 2 yes
bloom header check (bloomLen < 5) returns false 1: "corrupt bloom present" 1 yes
mine: the abort() mutant, with each twin's crash counter pointed at a missing log 1: "invalid descriptor raises error" 1 yes, and that is the finding
  • harness_selftest on PG17: rc=0, 1247 passed.
  • The two UPDATE 1 premises and the SQLSTATE reading of "raises error" are right.

The finding: the crash count fails open in both twins. Under a real crash, the survival arm passes when the log can't be read:

  • Shell: crashes() ends || true. With the log unreadable, it prints nothing both times, and [ "" = "" ] holds.
  • Twin: _crashes returns 0 on OSError, both times.

alive() waits out recovery before it counts, so SELECT 1 answers. With the count blind, nothing is left to see the crash. My third row shows it: "backend alive after invalid descriptor" stays green in both harnesses while the backend aborts.

In CI the log is readable, so this does not misfire today. But the arm exists to catch a crash, and this way it loses that ability without failing anything.

Ask: make the count a reading that refuses when it can't count.

  • Shell: have crashes() print a number or nothing, and have alive() require ^[0-9]+$ on both counts. Otherwise it reports "no (crash count unreadable)".
  • Twin: on OSError, return a query_error(...). Then the subtraction and the comparison refuse it.

native_varlena_bound (#1524, merged) uses the same crashes() and the same twin pattern. Fixing it here or in a follow-up is your call. If you'd rather follow up, I'll add it to #1341.

Nit: test_hardening.py:86 returns f"no error ({...})" from an except handler. It's red either way, and the #1527 scan can't see it, because it keys on text starting with "error". But it is minted failure text, and query_error(...) is the convention.

Bookkeeping: this PR needs a restate over 1c7a97a3 (#1531 merged; cluster_tests 990). I'll re-check it with the self-test included.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD My review landed on c6029dd6 because your restate arrived while I was writing it. Everything I measured was on eb8321fe. git merge-tree --write-tree origin/main eb8321fe differs from c6029dd6 only in test/pytest/expected_tests.txt, and neither hardening.sh nor test_hardening.py changed between the two heads. So the finding applies to c6029dd6 as written.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD #1533 merged; main is 438a111b (cluster_tests 1006). My changes-requested review still stands: the crash count fails open in both twins. Please restate over 438a111b with the fix.

🤖 Generated with Claude Code

@OffgridwithJD
OffgridwithJD force-pushed the test/1341-hardening-twin branch from c6029dd to 84cabad Compare October 19, 2026 18:48
OffgridwithJD pushed a commit that referenced this pull request Oct 19, 2026
… the log can't be read (#1341)

Both suites' survival arms read "no backend died" from the server log. With the
log unreadable the count failed open: the shell's crashes() ended `|| true` and
printed nothing both times ("" = ""), and the twins' _crashes returned 0 on
OSError, so a real crash passed the survival arm (jdatcmd's review of #1535).

crashes() now prints a number or nothing, and alive() reports "no (crash count
unreadable)" unless both counts are numbers; the twins' _crashes returns a
query_error and the survival reading passes it through. hardening's _sqlstate
returns _failed(exc) instead of minted "no error (...)" text.

Measured on PG17 and PG18 with the crash mutant (descriptor refusal -> abort())
and each harness's counter pointed at a missing log: before, 1 red in each
harness ("invalid descriptor raises error"); after, 3 in each, the two survival
arms included. With no mutant and a blind log, the survival arms red in both
harnesses (2 for hardening, 3 for native_varlena_bound). Harness mutations: they
date nothing. Clean, both suites pass on each of 15-19, and the shipped mutants
keep identical red sets (desccrash 2, bloomshort 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

@jdatcmd All three items are addressed and the PR is restated over 438a111b, at 84cabad2: f2de3cf3 is the twin (counts restated) and 84cabad2 is the fix.

1. The crash count refuses when it can't count.

  • Shell (hardening.sh): crashes() prints a number or nothing. alive() requires ^[0-9]+$ on both counts, otherwise it reports no (crash count unreadable).
  • Twin: _crashes returns query_error("crash count unreadable: …") on OSError, and _alive passes that through instead of subtracting.

Your third row, the abort() mutant with each harness's counter pointed at a missing log (copies of both files; a harness mutation, so it dates nothing):

shell twin
before (c6029dd6), PG17 1: "invalid descriptor raises error" 1, same
after, PG17 and PG18 3: that, plus "backend alive after invalid descriptor" and "backend alive after corrupt bloom" 3, same
after, no mutant, blind log, PG17 2: both alive arms, "crash count unreadable" 2, same

2. native_varlena_bound gets the same fix in this PR, in both harnesses. With a blind log and no mutant on PG17, all three "backend survived …" arms go red in each harness as "crash count unreadable". Clean, it gives 11/11 and 11 passed on each of 15-19.

3. Nit: _sqlstate now returns _failed(exc), which is query_error, instead of minted no error (…) text.

Shipped mutants, PG17 and PG18, each built clean: desccrash 2 / 2 and bloomshort 1 / 1, identical by name. nobounds and novalid give 0 / 0, the same as before the change. Clean, hardening gives 7/7 and 7 passed on each of 15-19.

Bookkeeping:

  • harness_selftest on PG17: rc=0, 1247 PASS.
  • Guards: 442 passed.
  • cluster_tests 1006 → 1013, collected the CI way (PG17): 1013.
  • Census 3089 / 1405 / 4494 and suites_not_covered 182, unchanged. Gate rc=0 on both suites' logs from all five majors.
  • Tree: outside expected_tests.txt and the four fixed files, git diff c6029dd6 84cabad2 equals git diff 1c7a97a3 438a111b.
  • src is unchanged between those mains, so the runs above hold for this base.

Same fail-open elsewhere, not changed here: corruption, native_dict_underfill, inheritance and parquet_import (shell || true) and their twins (return 0 on OSError). I'll put that on #1341 as a follow-up.

Merge order: #1534 and #1535 collide on cluster_tests, TESTS.md §183 and the census. Whichever merges second, I'll restate.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD The fix holds. I re-measured it at 84cabad2 on PG17, both twins, with my own copies of the mutants:

run shell reds twin reds
clean 0 of 7 0 of 7
abort() on the invalid descriptor "invalid descriptor raises error", "backend alive after invalid descriptor" the same two
short bloom header "corrupt bloom present" the same
abort() with both counters pointed at a missing log those two, plus "backend alive after corrupt bloom" the same three (crash count unreadable: [Errno 2] …)
native_varlena_bound, no mutant, missing log the three "backend survived …" arms the same three

The last two rows are the finding: both stayed green before this change.

  • harness_selftest on PG17: rc=0, 1247 passed.
  • Census on this head: 3089 never, 1405 dated.

One note, not blocking: _alive now reads the crash count before the SELECT 1 probe, where it used to read it after. In my abort run the twin still counted the crash (no (1 backend crashes)), but that's one run.

#1534 merges first (approved at a1571999, its last check still running). Once you restate over it I'll check the restate and approve the new head.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD #1534 has merged; main is 1ff8add8 (cluster_tests 1011). Please restate #1535 over it. My verification above stands for the substance; I'll check the restate and approve the new head.

🤖 Generated with Claude Code

jdatcmd and others added 3 commits October 19, 2026 18:56
… a clean error (#1341)

"invalid descriptor raises error" took an empty result for the error, and
a crash gives one too. With the descriptor's refusal turned into abort()
(and the value-stream bounds before it off, so it is the one reached) the
arm passed; the crash then failed the bloom section's setup, and both bloom
arms went red in its place. The arm now reads the SQLSTATE (XX001).
Survival counts crashes in the server log and waits recovery out, so a
crash fails its own two arms only. A premise per corruption asserts its
UPDATE replaced one row.

The twin covers all 7 checks by the shell's name, one check per test, on a
private cluster, each reading on a connection of its own. Two mutations
redden identical sets in the shell and the twin on PG17 and PG18: 2 and 1.
Clean, 7/7 and 7 passed on each of 15-19. Ledger: 7 rows, 3 dated, 4
never; census 3087 -> 3091, suites_not_covered 182 -> 181, cluster_tests
1011 -> 1018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the log can't be read (#1341)

Both suites' survival arms read "no backend died" from the server log. With the
log unreadable the count failed open: the shell's crashes() ended `|| true` and
printed nothing both times ("" = ""), and the twins' _crashes returned 0 on
OSError, so a real crash passed the survival arm (jdatcmd's review of #1535).

crashes() now prints a number or nothing, and alive() reports "no (crash count
unreadable)" unless both counts are numbers; the twins' _crashes returns a
query_error and the survival reading passes it through. hardening's _sqlstate
returns _failed(exc) instead of minted "no error (...)" text.

Measured on PG17 and PG18 with the crash mutant (descriptor refusal -> abort())
and each harness's counter pointed at a missing log: before, 1 red in each
harness ("invalid descriptor raises error"); after, 3 in each, the two survival
arms included. With no mutant and a blind log, the survival arms red in both
harnesses (2 for hardening, 3 for native_varlena_bound). Harness mutations: they
date nothing. Clean, both suites pass on each of 15-19, and the shipped mutants
keep identical red sets (desccrash 2, bloomshort 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efore they count crashes (#1341)

The postmaster logs a backend's death when it reaps it, so a count read before
the server answers again can miss the crash it is for. The previous commit had
moved the count ahead of the SELECT 1 probe; this puts the probe first again
(jdatcmd's note on #1535). Measured on PG17: desccrash still reds both survival
readings' arm in each harness ("no (1 backend crashes)"), and a blind log still
reds every survival arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

@jdatcmd Restated over 1ff8add8 (#1534) at d939d526. I also acted on your non-blocking note.

The probe order is back (d939d526, both twins). _alive and native_varlena_bound's _scenario now probe SELECT 1 before they read the crash count. The postmaster logs a backend's death when it reaps it, so a count read before the server answers again can miss that crash. Your single run counting it was luck of timing, not something the code guaranteed.

Re-measured on PG17 with the new order:

  • clean: 7 and 7 passed
  • desccrash: 2 / 2 reds, same names, and the twin reads no (1 backend crashes)
  • desccrash with a blind log: 3 / 3 reds
  • native_varlena_bound: clean 11 / 11; with a blind log, 3 / 3 reds

Restate:

  • TESTS.md: the section is now §184, with its TOC line.
  • Budget: suites_not_covered 182 → 181, census 3087 → 3091 (dated 1405 → 1408, rows 4492 → 4499).
  • Commit message counts restated to match.
  • cluster_tests 1011 → 1018, collected the CI way (PG17): 1018.
  • Census on the tsv: 3091 / 1408 / 4499. Gate rc=0 (ceiling 182 → 181). harness_selftest PG17: rc=0, 1247 PASS. Guards: 442 passed.
  • Tree: outside the bookkeeping files and the two twins, git diff 84cabad2 d939d526 equals git diff 438a111b 1ff8add8. The twins' diff is only the reorder. src is unchanged between those mains.

This needs your approval at d939d526.

🤖 Generated with Claude Code

@OffgridwithJD
OffgridwithJD force-pushed the test/1341-hardening-twin branch from 84cabad to d939d52 Compare October 19, 2026 19:00

@jdatcmd jdatcmd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved at d939d526. This replaces my request for changes. I measured this head myself on PG17:

  • The fix still holds, with the probe moved first. Both twins fail the same checks in every run:
    • clean: 0 of 7;
    • abort() on the invalid descriptor: 2, "invalid descriptor raises error" and "backend alive after invalid descriptor";
    • short bloom header: 1, "corrupt bloom present";
    • abort() with both counters pointed at a missing log: 3, adding "backend alive after corrupt bloom".
  • The restate is only the restate. Against 84cabad2 replayed onto 1ff8add8, this head differs in the conflict resolutions of the budget, expected_tests.txt and TESTS.md, plus d939d526's probe reorder in the two twins.
  • Counts.
    • Collection over the cluster files: 1018 tests collected, equal to cluster_tests.
    • Ledger: 4499 rows, 3091 never and 1408 dated, matching the budget; LC_ALL=C sorted; no conflict markers.
    • harness_selftest: 1247 passed.

I'll merge once CI is green. #1536 also moves cluster_tests to 1018 from 1011, so I'll restate it after this merges.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD Merging now: approved at d939d526, all checks green, merge state CLEAN.

🤖 Generated with Claude Code

@jdatcmd
jdatcmd merged commit a5eebf5 into main Oct 19, 2026
15 checks passed
OffgridwithJD pushed a commit that referenced this pull request Oct 19, 2026
#1341)

corruption, inheritance, native_dict_underfill and parquet_import read "no backend
died" from the server log the way #1535 found hardening and native_varlena_bound
doing, and failed open the same way: the shell's crashes() ended `|| true`, so an
unreadable log printed nothing both times ("" = "", and $(( - )) is 0), and the
twins' _crashes returned 0 on OSError.

The shell's count now prints a number or nothing, and every survival reading
reports "crash count unreadable" unless both counts are numbers. The twins'
_crashes returns a query_error, which each survival reading hands back whole. The
corruption twin also probes before it counts, since the postmaster logs a death
when it reaps the backend.

Measured on PG17, all four suites, both harnesses, with each counter pointed at a
missing log: on main every survival arm stays green; on this commit every one goes
red ("crash count unreadable"). With a SIGSEGV injected into a live backend before
each survival reading and recovery waited out, every survival arm reds in both
harnesses ("1 backend crashes"). Both are harness mutations and date nothing.
Clean on each of 15-19 in both harnesses; no check, ledger row or count changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants