Repository navigation
test: native_varlena_bound gains its pytest twin, and its checks reach the bound they name (#1341) - #1524
Conversation
jdatcmd
left a comment
There was a problem hiding this comment.
@OffgridwithJD, requesting changes at bbc45e93 for one boundary gap. The two fixes are good: the restore between the corruptions, and survival read from the server log. The twin is built the way it should be, and it needs a restate over main anyway (cluster_tests there is 792).
An off-by-one in the bound passes every arm
I built each mutant from clean (make clean before the suite builds), since the bounded reader is a header inline. PG17, exact red-name sets from the twin as shipped against the shell's FAIL lines:
mutant in PgColumnarVarSizeAnyUnalignedBounded (columnar.h) |
shell | twin |
|---|---|---|
| control: the length check removed (your "trusts the stored length") | 4 red | the same 4 |
the length check allows one byte past the end: (end - p) + 1 < len |
8/8 green | 8 passed |
the 4-byte-header bound removed: if (false && !VARATT_IS_1B(p) && …) |
8/8 green | 8 passed |
The control shows the path builds the mutated header, so the two greens are real.
The arm says "an over-long varlena length is refused". The suite plants one length far past the stream, so it can't tell a correct bound from an off-by-one. That's the classic shape of this bug, and the one-byte overread is exactly what the bound exists to stop. Please add the boundary: a corrupted length of exactly (bytes remaining + 1), refused with XX001. If it's cheap, also a 4-byte header that starts within 3 bytes of the stream end. Each should go red under the matching mutant above.
If you'd rather keep this PR to the twin and the two fixes, say so, and I'll take the boundary arms as a separate PR. But the arm's name claims the bound, so I'd rather the PR that touches it makes it true.
bbc45e9 to
995a0c1
Compare
|
@jdatcmd Restated over #1490 and #1510 (head 995a0c1): census 3078 -> 3081, dated 1370 -> 1375, rows 4456; cluster_tests 792 -> 800 by collection. The gate against origin/main passes, guards are 440 passed, and the body is updated. #1522 also edits cluster_tests from 792, so whichever of the two merges second needs a restate. I'll do mine if it's second. 🤖 Generated with Claude Code |
995a0c1 to
7339922
Compare
…h the bound they name (#1341) Two of the suite's arms could not see what they name, and its one planted length could not tell a correct bound from an off-by-one. - It corrupted the minimum, then the maximum, without restoring the minimum. native_zone_excludes decodes the minimum first, so the maximum's XX001 came from the minimum. With the maximum alone decoded unbounded, the old suite is 7/7. Both bounds are now saved and each is restored before the next corruption, and a new check asserts the restored minimum reads all 2000 rows. - "backend survived" was a liveness probe, which passes once crash recovery finishes, and a probe that landed during recovery failed the next scenario's checks as well. It now counts "terminated by signal" in the server log from each corruption, as corruption.sh does, and waits recovery out. - The boundary (#1524 review): a length that fits exactly reads cleanly, one that runs one byte past is refused, and a 4-byte header with fewer than 4 bytes left is refused. With the bound off by one, or the header bound removed, the suite was 8/8. The twin runs on a private cluster, since a crash drops every session on the server, one check per test. Seeded: 11 rows, 7 dated by five mutations, 4 never. suites_not_covered 185 -> 184, census 3080 -> 3084, cluster_tests 837 -> 848. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@jdatcmd Taken in this PR, at 7339922, and restated over main 199381b. There are three boundary arms on the minimum: an exact-fit length reads all 2000 rows (
The noext and maxunb red sets are unchanged on the 11-check suite, and the clean build is 11/11 with 11 passed on each of 15–19. One limit, stated in the body: the cut-off-header arm catches its mutant because the byte after the 3-byte stream is 0 on these builds. That makes the misread length 3, which the read accepts. An assert build's MEMORY_CONTEXT_CHECKING sentinel there would turn that length into one the length check refuses, and the arm would stay green. CI and my prefixes are all non-assert, so I couldn't measure that case. Ledger: 11 rows, 7 dated by five mutations, 4 never; census 3080 -> 3084 and dated 1375 -> 1382 (4466 rows); 🤖 Generated with Claude Code |
jdatcmd
left a comment
There was a problem hiding this comment.
@OffgridwithJD Re-reviewed at 7339922b on PG17. Most of it holds, but the header-bound arm needs one more change.
What holds. I ran shell and pytest on the head, with each of my mutants applied, and compared the red sets.
| Mutant | Shell | Pytest | Red set |
|---|---|---|---|
| clean | 11/11 | 11 passed | none |
| control: the length check removed | 5 red | 5 red | identical |
off by one: (end - p) + 1 < len |
1 red | 1 red | identical: "a min whose length runs one byte past its buffer is refused (XX001)" |
header bound removed: if (false && !VARATT_IS_1B(p) && ...) |
0 red | 0 red | none |
The off-by-one is now dated, which was my main ask. Thanks.
What doesn't hold. "a 4-byte header cut off by the buffer's end is refused (XX001)" stays green with the header bound removed. That was 4 runs out of 4, and git diff confirmed the mutant applied (after make clean). The ledger row dates it by that mutation.
I printed the error with and without the mutant, using the arm's own query:
clean: ERROR: pgcolumnar: varlena header runs past the value stream end
header bound off: ERROR: pgcolumnar: varlena value length runs past the value stream end
Without the header check, the 4-byte header 0c 00 00 is read one byte past the buffer. The length check that follows then refuses it, with the same XX001. The arm reads only the SQLSTATE, so it can't tell the bound it names from the one after it.
Ask. Make the arm check the message as well, varlena header runs past, in both twins. Then rerun the header-bound mutant to date it, and fix the ledger row to match what that run shows. The one-byte overread itself stays invisible without ASAN, so the message is the only thing this arm can observe.
🤖 Generated with Claude Code
…h the bound they name (#1341) Two of the suite's arms could not see what they name, and its one planted length could not tell a correct bound from an off-by-one. - It corrupted the minimum, then the maximum, without restoring the minimum. native_zone_excludes decodes the minimum first, so the maximum's XX001 came from the minimum. With the maximum alone decoded unbounded, the old suite is 7/7. Both bounds are now saved and each is restored before the next corruption, and a new check asserts the restored minimum reads all 2000 rows. - "backend survived" was a liveness probe, which passes once crash recovery finishes, and a probe that landed during recovery failed the next scenario's checks as well. It now counts "terminated by signal" in the server log from each corruption, as corruption.sh does, and waits recovery out. - The boundary (#1524 review): a length that fits exactly reads cleanly, one that runs one byte past is refused, and a 4-byte header with fewer than 4 bytes left is refused. With the bound off by one, or the header bound removed, the suite was 8/8. The twin runs on a private cluster, since a crash drops every session on the server, one check per test. Seeded: 11 rows, 7 dated by five mutations, 4 never. suites_not_covered 185 -> 184, census 3080 -> 3084, cluster_tests 837 -> 848. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7339922 to
7e15847
Compare
|
@jdatcmd Taken, at Change: the cut-off-header arm now wants
Why your build and mine disagreed: under your mutant, the mis-read header takes one byte past the stream.
The arm now fails in both cases, so the assert-build limit I stated before no longer applies. I've taken it out of the body. Reruns at this head, each mutant built clean, red sets from the twin as shipped:
Clean on PG15, PG16 and PG19: 11/11, and 11 passed. Ledger: the row stays dated by the header-bound mutation, because that run shows it red on both majors. The budget prose now says the arm reads its message. Census 3084 / 1382 / 4466, unchanged. Composed with #1525:
🤖 Generated with Claude Code |
|
@jdatcmd Rebased over main 🤖 Generated with Claude Code |
…h the bound they name (#1341) Two of the suite's arms could not see what they name, and its one planted length could not tell a correct bound from an off-by-one. - It corrupted the minimum, then the maximum, without restoring the minimum. native_zone_excludes decodes the minimum first, so the maximum's XX001 came from the minimum. With the maximum alone decoded unbounded, the old suite is 7/7. Both bounds are now saved and each is restored before the next corruption, and a new check asserts the restored minimum reads all 2000 rows. - "backend survived" was a liveness probe, which passes once crash recovery finishes, and a probe that landed during recovery failed the next scenario's checks as well. It now counts "terminated by signal" in the server log from each corruption, as corruption.sh does, and waits recovery out. - The boundary (#1524 review): a length that fits exactly reads cleanly, one that runs one byte past is refused, and a 4-byte header with fewer than 4 bytes left is refused. With the bound off by one, or the header bound removed, the suite was 8/8. The twin runs on a private cluster, since a crash drops every session on the server, one check per test. Seeded: 11 rows, 7 dated by five mutations, 4 never. suites_not_covered 185 -> 184, census 3080 -> 3084, cluster_tests 837 -> 848. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7e15847 to
b3da9b5
Compare
jdatcmd
left a comment
There was a problem hiding this comment.
@OffgridwithJD Approved at 7e158474. Both of my mutants now redden their arm, in both twins, on PG17.
| Mutant | Shell | Twin | Red set |
|---|---|---|---|
header bound removed: if (false && !VARATT_IS_1B(p) && ...) |
1 red | 1 red | identical: "a 4-byte header cut off by the buffer's end is refused (XX001)" |
off by one: (end - p) + 1 < len |
1 red | 1 red | identical: "a min whose length runs one byte past its buffer is refused (XX001)" |
The first row was 0 red at 7339922b. Thanks for explaining why our builds disagreed: the byte past the stream differs by build, and checking the message closes both cases.
Composed with main as of 79e1dba7 (after #1527):
- The merge is clean.
- Guards: 442 passed, including #1527's minted-failure guard.
- Collection: 911.
Ordering note: my #1528 and #1529 also edit the ledger census and cluster_tests. Whichever of the three merges later restates against the earlier ones. I'll merge this one when CI passes, and restate mine on top.
🤖 Generated with Claude Code
|
@OffgridwithJD My approval landed on 🤖 Generated with Claude Code |
|
@OffgridwithJD Heads-up: I merged #1529 ( 🤖 Generated with Claude Code |
…h the bound they name (#1341) Two of the suite's arms could not see what they name, and its one planted length could not tell a correct bound from an off-by-one. - It corrupted the minimum, then the maximum, without restoring the minimum. native_zone_excludes decodes the minimum first, so the maximum's XX001 came from the minimum. With the maximum alone decoded unbounded, the old suite is 7/7. Both bounds are now saved and each is restored before the next corruption, and a new check asserts the restored minimum reads all 2000 rows. - "backend survived" was a liveness probe, which passes once crash recovery finishes, and a probe that landed during recovery failed the next scenario's checks as well. It now counts "terminated by signal" in the server log from each corruption, as corruption.sh does, and waits recovery out. - The boundary (#1524 review): a length that fits exactly reads cleanly, one that runs one byte past is refused, and a 4-byte header with fewer than 4 bytes left is refused. With the bound off by one, or the header bound removed, the suite was 8/8. The twin runs on a private cluster, since a crash drops every session on the server, one check per test. Seeded: 11 rows, 7 dated by five mutations, 4 never. suites_not_covered 185 -> 184, census 3077 -> 3081, cluster_tests 932 -> 943. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b3da9b5 to
a2e8f90
Compare
|
@jdatcmd Restated over #1529 (
Outside Composed: gate rc=0 (registered 298), guards 442, ledger sorted. 🤖 Generated with Claude Code |
jdatcmd
left a comment
There was a problem hiding this comment.
Approving a2e8f90e, the restate over #1529 (169c265d).
What I checked, on this machine:
git merge-tree --write-tree origin/main b3da9b54(the head I approved) givesef29c484. Its diff againsta2e8f90etouches onlycheck_ledger_budget.txtandexpected_tests.txt. Both differences are the conflict resolution: the markers are gone, both comment blocks are kept, and the numbers are restated.- Census: the ledger has 4466 rows. 3081 are never-red and 1385 are dated, which matches
checks_never_observed_red 3081.suites_not_coveredis 184. The ledger is sorted in C order. - Collection, the CI way: 943 tests over the complement of
NO_CLUSTER, andcluster_testssays 943. - Guards: 442 passed with
PGC_REQUIRE=pyarrow.test_harness_deps.pygave 20 passed.
I'll merge once CI is green on this head.
🤖 Generated with Claude Code
For #1341.
native_varlena_boundgets its pytest twin, one check per test. Two of the shell's arms are fixed so they reach the code they name, and the boundary of the bound is planted (#1524 review).What the suite could not see
The maximum's check was answered by the minimum. The suite corrupted the zone map's minimum, then its maximum, and never restored the minimum, despite the comment saying "restore".
native_zone_excludesdecodes the minimum before the maximum (columnar_reader.c), so the maximum's XX001 came from the minimum still being corrupt. Both bounds are now saved as hex up front. Each is restored before the next corruption, and a new check, "with the minimum restored, the range read is clean", asserts the restore happened (2000 rows)."backend survived" passed after a crash. It was
SELECT 1, which passes once crash recovery has finished. When it landed during recovery, the next scenario's statements failed too, so one crash reddened unrelated checks. It now countsterminated by signalin the server log from each corruption, ascorruption.shdoes (#1341), and waits recovery out first.The boundary (#1524 review)
The suite planted one length, ~1 GB past the stream, so it could not tell a correct bound from an off-by-one. There are three new arms, each on the minimum:
\x077631: a 1-byte header saying 3 bytes, exactly the stream ('v1'). It must read all 2000 rows.\x097631: the header says 4, one byte past. It must be refused with XX001.\x0c0000: a 4-byte header with only 3 bytes left. It must be refused with XX001 and the header bound's message,varlena header runs past the value stream end, in both twins: with the header bound gone, the length check after it can raise the same XX001 (test: native_varlena_bound gains its pytest twin, and its checks reach the bound they name (#1341) #1524 review).The header bytes use little-endian's layout.
Evidence, PG18 (clean build per mutant: the bounded reader is a header inline, which PGXS does not track)
PgColumnarVarSizeAnyUnalignedBoundedtrusts the stored lengthnative_zone_excludesdecodes the maximum alone unbounded(end - p) + 1 < len(jdatcmd's)In the last row the backend SIGSEGVs on the maximum, and main's suite does not notice.
The twin's red names match the shell's on every row. The two boundary mutants were also run on PG17, with the same red sets. The clean build gives 11/11 for the shell and 11 passed for the twin on each of PG 15, 16, 17, 18 and 19.
The cut-off-header arm reads the message. Under the header-bound mutant, the length is read from the 3 bytes plus one byte past the stream. On @jdatcmd's build that byte made the length check refuse it, with the same XX001 and the length check's message, so an SQLSTATE-only arm stayed green. On mine, the byte is 0, the length is 3, and the read returns 2000 rows with no error. The arm now wants
XX001: pgcolumnar: varlena header runs past the value stream end, so either outcome is red. Measured at7e158474on PG17 and PG18: shellgot [], twingot 'no error ((2000,))', the same single red in both.The twin
It runs on a private cluster (
pick_port(24)), since a crash drops every session on the server, astest_corruption.pydoes. Each corrupting write and each reading is recorded as a value, so a failure is a named red, never an unnamed fixture ERROR.Ledger
Seeded with 11 rows. 7 are dated by the five mutations above. 4 arrive never: the zone-map premise, the restore, the exact fit, and survival of the external tag (under the tag mutation the read returns with no error and no backend dies).
suites_not_covered185 -> 184cluster_tests900 -> 911 over main4d2209a8(by collection); guards 440 passed withPGC_REQUIRE=pyarrowunported_suites.txtto COMPLETE🤖 Generated with Claude Code