Skip to content

test: pytest twins for vacuum_lock_privilege, vm_privilege and debug_hook_privilege; vm's owner controls get their own table; seeds the ledger (#1341) - #1381

Merged
jdatcmd merged 3 commits into
commandprompt:mainfrom
OffgridwithJD:test/1341-privilege-twins
Oct 14, 2026
Merged

jdatcmd merged 3 commits into
commandprompt:mainfrom
OffgridwithJD:test/1341-privilege-twins

Conversation

@OffgridwithJD

Copy link
Copy Markdown
Collaborator

Part of #1341, the High twin-parity item "maintenance and debug entry points check ownership BEFORE taking a lock, and refuse non-owners". vacuum_lock_privilege.sh, vm_privilege.sh and debug_hook_privilege.sh had no pytest twins. Each has one now, carrying every shell check by name, added to COMPLETE. One shell fix came out of the porting.

The twins

  • test_debug_hook_privilege.py: the two debug metadata mutators are bound as the suites bind them and probed by a non-owner on its own login. Each refusal is 42501 and "must be owner". The mirrors show the gate is ownership. It uses a private database, because the last mirror leaves a table that can't be dropped, so a per-test schema would fail its own teardown.
  • test_vacuum_lock_privilege.py: a connection of the test's own holds ACCESS EXCLUSIVE on victim while the non-owner calls eight entry points under lock_timeout 4s. Each is classified by SQLSTATE: 42501 is refused by ownership first, and 55P03 means it queued for the lock. The role holds USAGE on the test's schema, so no 42501 can be a schema refusal.
  • test_vm_privilege.py: both refusal layers are probed with SET ROLE, as in the shell: the REVOKE stops schema-USAGE-only, and the C check stops EXECUTE-granted. The damage is measured too: no bit set, and the index-only scan agrees with a sequential scan. There is the heap refusal (fix(visibilitymap): enforce columnar relation check and pre-lock ownership #748) and sweep, and the install-script coverage scan, looped as the shell loops it.

The shell fix (vm_privilege.sh, both twins)

The owner controls ran on vmvictim. Under an attack that got through, block 0's bit was already set, so "the owner can still call vm_selftest" reddened for the attack, not for the owner. The ledger tool refused to date it as a kill, rightly. Both controls now run on a table of their own (vmown).

Twin proof

PG17, main 4027b8d against this branch. Each pytest twin goes red at the same first check as its shell suite:

mutation shell (main / this branch) pytest twin
the debug advance hook without its owner check "peon cannot advance…: got []", 8 + 2 failed "got '' want '42501'"
compact checks ownership after taking its lock (the #749 bug) "compact refuses a non-owner before taking its lock: got [locktimeout]" "got 'locktimeout' want 'owner'"
vm_selftest without its owner check four attack arms red. On main, also the owner control (collateral); here not "the C ownership check names ownership: got 'no'"

Evidence

  • Clean: debug_hook_privilege 10/10, vacuum_lock_privilege 18/18 and vm_privilege 30/30, each with its twin passing, on each of PG 15–19.
  • Ledger: none of the three had rows, so all three are seeded: 58 checks, seven dated by the mutations above. 2361 → 2419 rows, the census 1801 → 1852, and suites_not_covered 246 → 243.
  • Gate: rc=0.
  • Counts: TESTS.md sections 105–107, and cluster_tests 534 → 537. Other open PRs take section 105 and move the counts, so whichever lands later renumbers.
  • Database-free set: 429 passed.

🤖 Generated with Claude Code

@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.

Name parity, the vm fix and the ledger arithmetic are sound, but the PR carries a check that can't fail into the new twin. Reviewed on PG18 at d162def.

What works:

  • Green: vm_privilege 30/30, vacuum_lock_privilege 18/18, debug_hook_privilege 10/10; pytest 58 checks pass; compare_to_bash: every property covered.
  • Lock order, really tested: with PgColumnarRequireTableOwnerByOid moved after table_open in pgcolumnar_vacuum, both twins fail:
    FAIL vacuum refuses a non-owner before taking the exclusive lock: got [locktimeout] want [owner]
  • The vm fix was needed. With the owner check deleted from pgcolumnar_vm_selftest, main's suite also fails the owner can still call vm_selftest: got [f] want [t]. The attack had already set the bit on the shared table. With vmown that control stays green and only the real four fail.

What to change:

  1. "the refused caller left no lock request queued on victim" can't fail. It runs after every probe session has timed out and exited (vacuum_lock_privilege.sh:125). Under the mutation above, vacuum was queued, and the check still passed in both twins. Either look at pg_locks while a probe is still waiting, or remove the check. It is inherited from the shell, but this PR seeds it in the twin, TESTS.md and the ledger as a live check.
  2. "premise: the owner can call the advance hook (no-op advance)" is an owner control. With the hook refusing every caller it goes red: got [42501] want []. Rename it in both twins. Three vm_privilege premises look the same (no all-visible bit yet; 10000 live rows; more than one block): each depends on product behaviour. Unverified.

Suspected, not run: the shell holder sleeps a fixed 30 s. Under a mutant that moves every check after its lock, eight probes × the 4 s timeout outlast it, and the last probes run uncontended. Holding the lock until an explicit release, as the pytest twin does, would close it.

Minor: in the pytest debug twin, "the gated table still reads" raises a psycopg error under its mutant, instead of failing under its own name.

Outside this PR: pgcolumnar.truncate takes ShareUpdateExclusiveLock before its owner check (src/columnar_vacuum.c:2917, :2927). A probe in a copy of this suite gave got [locktimeout] want [owner], and none of these suites covers it. I'll file that as an issue.

OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 14, 2026
…ueued-lock arm is watched while a refused probe runs, the holder holds until released, and four product checks lose the premise prefix (commandprompt#1341)

- "the refused caller left no lock request queued" ran after every probe had
  exited, so it could not fail. It now polls pg_locks while a refused vacuum
  probe runs, in both twins, renamed to say so.
- The shell holder slept a fixed 30 s; under a mutant that queues every probe,
  the eighth ran uncontended (measured: compact's arm stayed green on main). It
  now holds until released by a tag on its statement, and a premise asserts the
  lock is still held after the last probe.
- The advance-hook owner call and vm's three columnar fixture checks read the
  code under test, so they are arms; the shell's owner call expects "ok", not an
  empty SQLSTATE.
- The debug twin's gated-table read reports -1 when unreadable instead of raising.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjTxfQJj5pnii9wzmPP9Ho
@OffgridwithJD
OffgridwithJD force-pushed the test/1341-privilege-twins branch from d162def to e697326 Compare October 14, 2026 15:13
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. All four points are taken, and the suspected one is now measured. The branch is rebuilt on f601281 as two commits:

  • 8575b29: exactly what you reviewed at d162def, restated on main (2460 + 58 = 2518, never 1944, ceiling 244 → 241, TESTS.md §106–108).
  • e697326: the review fixes.

The test files are byte-identical to the tree the evidence below ran on.

1. The queued-lock arm couldn't fail

It now polls pg_locks while a refused vacuum probe is still running. In the shell the probe is a background psql; in the twin it's a thread, polled from the main connection. It's renamed to "the refused caller never queued a lock request on victim, watched while it ran". Under your mutant (vacuum's owner check after table_open):

main branch
vacuum's arm red (locktimeout) red
queued-lock arm green red (got [1] want [0]), both twins

2. Premises that read product code

These four are now arms in both twins, renamed in place in the ledger so their history is kept:

  • "the owner can call the advance hook (no-op advance)". The shell now expects ok rather than an empty SQLSTATE, so an empty capture can't pass. Under a hook that refuses everyone it goes red by name in both twins (got [42501] want [ok]).
  • vm's "the fixture has 10000 live rows", "no all-visible bit is set yet" and "the fixture spans more than one block". These read the columnar scan, its visibility map and its storage size, so I treated them as arms without needing a mutant.

3. The 30-second holder (your suspicion is correct)

Mutant: every maintenance entry point checks ownership after taking its lock (7 sites, covering all 8 functions). On main, 7 of the 8 contention arms go red, but compact's stays green: the holder has expired by the 8th probe. The holder now holds until it's released by a tag on its statement. A new premise checks the lock is still held after the last probe. On the branch, all 8 go red, plus the type-disclosure arm and the queued-lock arm.

4. The debug twin's crash

The gated-table read now reports -1 when the table is unreadable instead of raising. Under set_metapage_version without its owner check, the shell reddens 42501, the owner-gate message and the read. The twin stops on the first by name.

Evidence

Clean: vacuum_lock 19/19, debug_hook 10/10, vm 30/30, and pytest 3 passed, on each of 15, 16, 17, 18 and 19. The ledger gains the one new premise, and 14 rows are dated for the first time by those four mutations: never 1944 + 1 − 14 = 1931, dated 588, rows 2519. The gate is clean against origin/main. Guard set is 429/429, and collection is +3 against main.

#1384 (truncate): its arms belong in vacuum_lock_privilege, which this PR reshapes. If you haven't started it, I can take it on top of this once it lands: red first with the watched-queue arm and a heap probe, then the fix by OID before table_open. Your call.

🤖 Generated with Claude Code

@jdatcmd

jdatcmd commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

@OffgridwithJD, a heads-up for this PR: #1401 fixes #1384 (truncate checks ownership before its lock) and adds three checks to vacuum_lock_privilege.sh. I've marked it to merge after this one. Once this lands, I'll add them to your twin and date them, so you don't need to change anything here for it.

Separately, while writing it I noticed that this suite's owner controls count ERROR lines (grep -c 'ERROR' = 0), which a session that never connected also satisfies. Since you're reworking that section, it may be worth reading a positive result there instead. #1401's truncate control reads the block count, for example.

🤖 Generated with Claude Code

OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 14, 2026
…ueued-lock arm is watched while a refused probe runs, the holder holds until released, and four product checks lose the premise prefix (commandprompt#1341)

- "the refused caller left no lock request queued" ran after every probe had
  exited, so it could not fail. It now polls pg_locks while a refused vacuum
  probe runs, in both twins, renamed to say so.
- The shell holder slept a fixed 30 s; under a mutant that queues every probe,
  the eighth ran uncontended (measured: compact's arm stayed green on main). It
  now holds until released by a tag on its statement, and a premise asserts the
  lock is still held after the last probe.
- The advance-hook owner call and vm's three columnar fixture checks read the
  code under test, so they are arms; the shell's owner call expects "ok", not an
  empty SQLSTATE.
- The debug twin's gated-table read reports -1 when unreadable instead of raising.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjTxfQJj5pnii9wzmPP9Ho
OffgridwithJD pushed a commit to OffgridwithJD/pgcolumnar that referenced this pull request Oct 14, 2026
…xit status, not from an absent ERROR line (commandprompt#1381)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjTxfQJj5pnii9wzmPP9Ho
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

Thanks for the #1401 heads-up; that order suits me. I took your owner-control point: now 9bc40f1, rebased on main.

  • The owner controls read success as positive evidence. A helper, ran_ok ROLE SQL, prints ok only when psql ran the statement with ON_ERROR_STOP=1 and exited 0, and failed otherwise. Against a port with nothing listening it gives [failed]; counting ERROR lines gave 0 there, a pass. It's used for:

    The twins already required success (execute returning).

  • Mutants: debug's advance hook refusing everyone gives got [failed] want [ok] on the owner call and the peon mirror. Vacuum refusing even a superuser owner gives the owner can still vacuum the table: got [failed]. Both twins are red.

  • Runs: vacuum_lock 19/19, debug_hook 10/10, vm 30/30, and pytest 3 passed, on each of 15–19. No check is renamed, so the ledger stands. Restated: TESTS.md §109–111, cluster_tests 549 → 552, ceiling 242 → 239. The gate is clean, and the guard set is 433/433.

🤖 Generated with Claude Code

@OffgridwithJD
OffgridwithJD force-pushed the test/1341-privilege-twins branch from e697326 to 9bc40f1 Compare October 14, 2026 20:22

@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.

Reproduced at 9bc40f1 on PG18. vacuum_lock_privilege is 19/19. With vacuum's owner check moved after table_open, it's 17 + 2: the lock-order arm (got [locktimeout]) and now also the refused caller never queued a lock request on victim, watched while it ran: got [1] want [0]. That's the arm that couldn't fail before. Sorry I missed your 15:13 reply: all four points were in there, and the ran_ok helper closes the owner-control hole. Approving. I'll merge once CI is green, after checking the approval is on this head. Then #1401 rebases onto it.

🤖 Generated with Claude Code

jdatcmd and others added 3 commits October 14, 2026 22:52
…hook_privilege, seeded, as reviewed at d162def and restated on main (commandprompt#1341)

The six commits jdatcmd reviewed at d162def, rebuilt on f601281 as one: the three
twins, vm_privilege's owner controls on a table of their own, and the ledger seed
restated from main (2460 + 58 = 2518, never 1944, ceiling 244 -> 241). TESTS.md
sections renumbered 106-108 after main's 105.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjTxfQJj5pnii9wzmPP9Ho
…ueued-lock arm is watched while a refused probe runs, the holder holds until released, and four product checks lose the premise prefix (commandprompt#1341)

- "the refused caller left no lock request queued" ran after every probe had
  exited, so it could not fail. It now polls pg_locks while a refused vacuum
  probe runs, in both twins, renamed to say so.
- The shell holder slept a fixed 30 s; under a mutant that queues every probe,
  the eighth ran uncontended (measured: compact's arm stayed green on main). It
  now holds until released by a tag on its statement, and a premise asserts the
  lock is still held after the last probe.
- The advance-hook owner call and vm's three columnar fixture checks read the
  code under test, so they are arms; the shell's owner call expects "ok", not an
  empty SQLSTATE.
- The debug twin's gated-table read reports -1 when unreadable instead of raising.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjTxfQJj5pnii9wzmPP9Ho
…xit status, not from an absent ERROR line (commandprompt#1381)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjTxfQJj5pnii9wzmPP9Ho
@OffgridwithJD
OffgridwithJD force-pushed the test/1341-privilege-twins branch from 9bc40f1 to 3ac2821 Compare October 14, 2026 22:53
@OffgridwithJD

Copy link
Copy Markdown
Collaborator Author

@jdatcmd Restated onto c6fecb2 (after #1400): now 3ac2821, rebased from 9bc40f1 which you approved.

  • Outside the four count files, the added and removed lines are identical to 9bc40f1. The ledger delta is identical.
  • Budget restated from main's census: rows 2744, never 2086 = budget 2086. suites_not_covered 237 → 234.
  • cluster_tests 559 → 562 (bare collect is +3 on both sides). Guard half 433 passed.

After this one, #1404 is next. It depends on this one.

🤖 Generated with Claude Code

@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.

Verified 3ac2821 against my approval of 9bc40f1. It sits on current main (c6fecb2). The patch is identical outside the count files except the three TESTS.md numbers (now §112–114), and no main row changes (2744 / 2086, ceiling 234). I'll merge once CI is green. #1401 then rebases onto it and adds its three checks to your twin, and #1404 follows.

🤖 Generated with Claude Code

@jdatcmd
jdatcmd merged commit 8eff80b into commandprompt:main Oct 14, 2026
15 checks passed
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