Opus 5 found the $SUBJECT regression from commit 630706c "Add pg_iswcased()".
That finding still holds at today's master. I am attaching the LLM's report
and test case.
commit 99d60ed (c1/copilot/630706c-defect-tests)
Author: Noah Misch <[email protected]>
AuthorDate: Fri Jul 31 22:47:08 2026 +0000
Commit: Noah Misch
<n...@inst-builder-debian-13-build-build-g8q13.us-central1-b.c.gce-image-builder.internal>
CommitDate: Fri Jul 31 22:47:08 2026 +0000
Add TAP test for wrong indexed ILIKE in libc multibyte DBs
Commit 630706ced0 ("Add pg_iswcased().") introduced pg_iswcased() and
wired the single-byte classifier wc_iscased_libc_sb into
ctype_methods_libc_other_mb, the ctype method table used by libc
databases in a non-UTF8 multibyte encoding (EUC_JP, EUC_CN, EUC_KR,
EUC_TW, EUC_JIS_2004, MULE_INTERNAL). Once dbbeafe added the
"wc > UCHAR_MAX" guard to that classifier, pg_iswcased() reports every
multibyte character as uncased. like_fixed_prefix_ci() (added later by
9c8de15) then files a cased fullwidth letter into a case-sensitive
B-tree prefix range, so an indexed ILIKE silently returns fewer rows
than a sequential scan of the same table. This is still present in
master.
The new test in src/test/modules/test_ilike_libc_mb/ initdbs a libc
EUC_JP cluster (skipping cleanly on Windows, when ja_JP.eucjp is
absent, or when initdb refuses the encoding) and runs one ILIKE query
whose pattern is a cased fullwidth letter, once as a forced sequential
scan and once as a forced index scan. It asserts that the two plans
return identical row sets and that both return the uppercase and
lowercase fullwidth rows. On master the index scan returns 1 row while
the sequential scan returns 2, so the test fails with "index scan
returned 1 rows, seq scan returned 2 rows". The oracle asserts the
required end state and is agnostic to the eventual fix.
Do not fix the defect here; the test is meant to fail on master.
DEFECTS_630706c.md in the new module documents the root cause (including
why the obvious wc_iscased_libc_mb swap is insufficient, because for
non-UTF8 multibyte encodings pg_wchar is packed encoded bytes rather
than a libc wchar_t), the rejected candidates, and full provenance.
Co-authored-by: Copilot <[email protected]>
---
src/test/modules/Makefile | 1 +
src/test/modules/meson.build | 1 +
.../modules/test_ilike_libc_mb/DEFECTS_630706c.md | 378 +++++++++++++++++++++
src/test/modules/test_ilike_libc_mb/Makefile | 16 +
src/test/modules/test_ilike_libc_mb/meson.build | 12 +
.../test_ilike_libc_mb/t/001_ilike_eucjp.pl | 239 +++++++++++++
6 files changed, 647 insertions(+)
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 098bb81..a034f48 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -32,6 +32,7 @@ SUBDIRS = \
test_escape \
test_extensions \
test_ginpostinglist \
+ test_ilike_libc_mb \
test_int128 \
test_integerset \
test_json_parser \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42b..73212d6 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -33,6 +33,7 @@ subdir('test_dsm_registry')
subdir('test_escape')
subdir('test_extensions')
subdir('test_ginpostinglist')
+subdir('test_ilike_libc_mb')
subdir('test_int128')
subdir('test_integerset')
subdir('test_json_parser')
diff --git a/src/test/modules/test_ilike_libc_mb/DEFECTS_630706c.md
b/src/test/modules/test_ilike_libc_mb/DEFECTS_630706c.md
new file mode 100644
index 0000000..105a976
--- /dev/null
+++ b/src/test/modules/test_ilike_libc_mb/DEFECTS_630706c.md
@@ -0,0 +1,378 @@
+# User-visible defect from commit 630706c ("Add pg_iswcased().") still present
in master
+
+Commit **630706ced04e3a7a7f0070f4e8fb88f7503a1016** ("Add pg_iswcased().")
+introduced `pg_iswcased(wc, locale)` — "True if character has multiple case
+forms" — as a multibyte-aware replacement for the older `char_is_cased()` API.
+It added two libc implementations, `wc_iscased_libc_sb` (single-byte) and
+`wc_iscased_libc_mb` (wide-character), and wired one into each libc ctype
method
+table. For the table used by **non-UTF8 multibyte** libc databases
+(`ctype_methods_libc_other_mb`, covering EUC_JP, EUC_CN, EUC_KR, EUC_TW,
+EUC_JIS_2004 and MULE_INTERNAL) it installed the **single-byte** classifier.
+
+Once the `wc > UCHAR_MAX` guard was later added to that single-byte classifier
+(dbbeafe, "pg_locale_libc.c: add guards to ctype methods."), `pg_iswcased()`
+began returning **false for every multibyte character** in those encodings.
+When `like_fixed_prefix_ci()` (added by 9c8de15, "Use multibyte-aware
extraction
+of pattern prefixes.") started consuming `pg_iswcased()` to extract the fixed,
+case-invariant prefix of an `ILIKE` pattern for a B-tree index range, a cased
+fullwidth letter is mis-classified as uncased and filed into a
**case-sensitive**
+prefix range. The result is a **wrong query result**: an indexed `ILIKE`
+silently returns fewer rows than a sequential scan of the same table for the
+same query.
+
+The defect is **still present in master** at
+`481052c7754013d44a89f50912ba6845398f88df`. This report accompanies the TAP
+test `src/test/modules/test_ilike_libc_mb/t/001_ilike_eucjp.pl`, which encodes
+the *correct* behavior and therefore **fails on unmodified master** with:
+
+```
+not ok 7 - EUC_JP: indexed and sequential ILIKE agree (index scan returned 1
rows, seq scan returned 2 rows)
+```
+
+This report is produced by an automated defect-hunting workflow.
+
+---
+
+## The verbatim user prompt that drove this workflow
+
+```
+Make a large workflow, with at most 90 agents, to write test cases covering
+user-visible defects in these commits:
+
+f81bf78ce12b9fd3e50eb00dd875440007262ec4
+147602822597204aa436415ebe295926b268ab5c
+19b966243c38196a33b033fb0c259dcf760c0d69
+630706ced04e3a7a7f0070f4e8fb88f7503a1016
+87b2968df0f866aaccb6ba69adf284e3c4a79454
+0a90df58cf38cf68d59c6841513be98aeeff250e
+8185bb53476378443240d57f7d844347d5fae1bf
+dbf217c1c7c2744a18db489c255255e07cfbb110
+
+Use your own worktrees; disregard the present dir except as repository to
+which to attach your worktree. The workflow should first look for extant
+user-visible defects (still present in master). If it finds any, write a test
+case covering some of those defects. If any defects found weren't suitable to
+test, describe them in a report. Include in the report the prompt I used, the
+latest hash of the branches you consulted, model version, etc. Commit the
+tests and the report (if any) on a fresh branch per commit under review.
+```
+
+---
+
+## Provenance
+
+* **Repository:** PostgreSQL, git dir `/home/nm/src/pg/postgresql`.
+* **Branch consulted:** `master` (== `origin/master`) at
+ **`481052c7754013d44a89f50912ba6845398f88df`** ("On Windows, make link(2)
+ report ENOTSUP when appropriate.", CommitDate 2026-07-31 14:39:37 -0400).
+ All worktrees were created from exactly this commit. **No other branch was
+ consulted.**
+* **Commit under review:** `630706ced04e3a7a7f0070f4e8fb88f7503a1016`
+ ("Add pg_iswcased().", Jeff Davis, 2025-12-10).
+ **Parent:** `1e493158d3d25771ed066028c00cbbdb41573496`
+ ("Remove char_tolower() API.").
+* **Agent / models:** GitHub Copilot CLI v1.0.77; orchestrating model
+ **Claude Opus 5** (`claude-opus-5`). The hunt phase deliberately used **six
+ different LLM models** for finding diversity — `claude-opus-4.8`, `gpt-5.5`,
+ `claude-sonnet-4.6`, `gemini-3.1-pro-preview`, `gpt-5.6-sol`,
+ `claude-opus-4.6` — and triage ran on `claude-opus-4.8`.
+* **Host / toolchain:** Debian; gcc (Debian 14.2.0-19) 14.2.0; ICU 76.1;
+ meson 1.7.0 / ninja 1.12.1; 8 CPUs; `postgres (PostgreSQL) 20devel`.
+* **Build configuration** of every worktree:
+ `meson setup build --prefix=<worktree>/inst -Dcassert=true
+ -Dtap_tests=enabled -Dicu=enabled -Dssl=openssl -Dbuildtype=debug`.
+* **OS locales generated for the hunt** (`locale -a`): `C C.utf8 POSIX
+ de_DE.iso88591 de_DE.utf8 el_GR.utf8 en_US.iso88591 en_US.utf8
fr_FR.utf8
+ ja_JP.eucjp ja_JP.utf8 lt_LT.utf8 ru_RU.koi8r tr_TR.iso88599
tr_TR.utf8`.
+ Of the non-UTF8 multibyte locales, only **`ja_JP.eucjp`** (EUC_JP) is
+ installed on this host; the EUC_KR and EUC_CN arms of the test skip cleanly.
+
+### Workflow shape
+
+The user asked for a "large workflow" of at most 90 agents. It ran in four
+waves over roughly seventy agents. **Wave 0** (orchestrator) attached one
+dedicated git worktree and a fresh `copilot/<short>-defect-tests` branch per
+commit under review (eight commits). **Wave 1** (hunters) ran five to six
+independent read-only hunter agents on each commit — spread across the six LLM
+models listed above so that different reasoning styles would surface different
+bugs — each following a fixed method: study the diff and its master context,
+check `git log <commit>..master` for follow-up fixes, analyze from an assigned
+angle (diff-semantics, consumer-side, libc internals, Unicode/encoding,
+robustness), and empirically reproduce every candidate against a server built
+from master. **Wave 2** (triage, one agent per commit) verified, deduplicated
+and classified the hunters' candidates into TESTABLE vs REPORT-ONLY vs
REJECTED,
+re-reproducing the survivors from scratch. **Wave 3** (writers, one agent per
+commit — this agent) wrote the in-tree test and this report and committed them.
+For commit 630706c the same wrong-results defect (T1 below) was found
+independently by four hunters running on four different models, which is why it
+is the flagship result of the run.
+
+---
+
+## Defects covered by the accompanying test
+
+### T1 — Indexed `ILIKE` drops case-equivalent rows in non-UTF8 multibyte libc
databases
+
+* **File:line (master `481052c`):**
+ * `src/backend/utils/adt/pg_locale_libc.c:438` —
+ `ctype_methods_libc_other_mb.wc_iscased = wc_iscased_libc_sb` (**the wrong
+ pointer**; compare `:461`, where the UTF8 table uses `wc_iscased_libc_mb`).
+ * `src/backend/utils/adt/pg_locale_libc.c:209-215` — `wc_iscased_libc_sb()`,
+ whose body is `if (wc > UCHAR_MAX) return false;` then
+ `isupper_l((unsigned char) wc, …) || islower_l((unsigned char) wc, …)`.
+ * `src/backend/utils/adt/pg_locale_libc.c:837-840` — selection: a non-C-ctype
+ libc locale gets `ctype_methods_libc_other_mb` exactly when
+ `GetDatabaseEncoding() != PG_UTF8 && pg_database_encoding_max_length() >
1`.
+ * `src/backend/utils/adt/like_support.c:1135` — the consumer:
+ `if (pg_iswcased(wpatt[wpos], locale)) break;` inside
+ `like_fixed_prefix_ci()`.
+ * `src/backend/utils/adt/pg_locale.c:1615` and
+ `src/include/utils/pg_locale.h:221` — the `pg_iswcased()` dispatch and
+ declaration.
+
+* **Root cause.** `pg_iswcased()` must answer "does this character have more
+ than one case form?" so that `like_fixed_prefix_ci()` can decide whether a
+ leading pattern character may be used verbatim in a case-**sensitive** B-tree
+ index range. For a non-UTF8 multibyte libc database the call lands in
+ `ctype_methods_libc_other_mb.wc_iscased`, which commit 630706c set to the
+ single-byte `wc_iscased_libc_sb`. That function only inspects the low byte
of
+ `wc` (`(unsigned char) wc`) and, after dbbeafe's guard, returns `false`
+ outright for every `wc > UCHAR_MAX`. Every multibyte character in these
+ encodings has a `pg_wchar` above 255, so `pg_iswcased()` returns **false for
+ all of them** — including genuinely cased fullwidth Latin letters.
+ `like_fixed_prefix_ci()` then treats such a letter as a fixed, case-invariant
+ prefix and builds the index range `[A, greaterstr(A))`. In EUC_JP fullwidth
+ `A` = `0xA3C1` and fullwidth `a` = `0xA3E1`, and `greaterstr(A)` = `B` =
+ `0xA3C2`, so the range `[0xA3C1, 0xA3C2)` **excludes `a`** even though
+ `ILIKE 'A%'` must match it. The index scan therefore misses the lowercase
+ row while the sequential scan (which re-checks the full `ILIKE` predicate)
+ returns it.
+
+* **How 630706c and dbbeafe interact (be precise).**
+ * **630706c** created the `pg_iswcased()` contract and *installed the wrong
+ function pointer*: `wc_iscased_libc_sb` in `ctype_methods_libc_other_mb`
+ instead of a multibyte-aware classifier. As originally committed,
+ `wc_iscased_libc_sb` had no range guard and simply truncated `wc` to its
low
+ byte (`(unsigned char) wc`), so for a multibyte character it returned an
+ ill-defined, byte-dependent answer. At that point the value was computed
+ but not yet consumed anywhere, so it was latent.
+ * **dbbeafe** ("pg_locale_libc.c: add guards to ctype methods.",
+ Reported-by: Noah Misch) added `if (wc > UCHAR_MAX) return false;` to
+ `wc_iscased_libc_sb` — a correct and necessary guard for the single-byte
+ table and for 16-bit `wchar_t` platforms. Applied to the *mis-wired*
+ multibyte table, it converted 630706c's ill-defined truncation into a
+ deterministic, portable **"false for every multibyte character."**
+ * **9c8de15** ("Use multibyte-aware extraction of pattern prefixes.", whose
+ message explicitly cites "the new pg_iswcased() API introduced in
+ 630706ced0") added the consumer `like_fixed_prefix_ci()`, turning the
+ always-false classification into user-visible **wrong query results**.
+
+ The defect is attributable to **630706c**: it defined the API and chose the
+ classifier that cannot describe the very characters the API exists to
+ describe. dbbeafe and 9c8de15 are the correct guard and the correct
consumer;
+ neither is wrong on its own.
+
+* **Why the "obvious" one-line fix is insufficient (verified, and a correction
+ to the triage's stated fix).** The natural fix suggested by the sibling
+ tables — point `ctype_methods_libc_other_mb.wc_iscased` at the wide-character
+ `wc_iscased_libc_mb`, as the UTF8 table does — **does not fix the bug.** I
+ built it and re-ran the test; the index scan still returns 1 row. The reason
+ is a `pg_wchar`/`wchar_t` representation mismatch:
+
+ * For **UTF8**, PostgreSQL's `pg_wchar` *is* the Unicode code point, and
glibc's
+ `wchar_t` for a UTF-8 locale is *also* the Unicode code point, so
+ `wc_iscased_libc_mb`'s `iswupper_l((wint_t) wc, …)` works. That is why the
+ UTF8 table is correct and UTF8 databases are unaffected.
+ * For **EUC_JP** and the other non-UTF8 multibyte encodings, `pg_mb2wchar`
+ packs the *encoded bytes* into `pg_wchar` — fullwidth `A` becomes `0xA3C1`
+ (`src/common/wchar.c:129-134`, `pg_euc2wchar_with_len`). glibc's `wchar_t`
+ for the same character in a `ja_JP.eucjp` locale is the Unicode value
+ `0xFF21`, **not** `0xA3C1`. So `iswupper_l(0xA3C1, ja_JP.eucjp)` is `0`:
+ `wc_iscased_libc_mb` returns `false` for these characters too. (Confirmed
+ with a small C program: `iswupper_l(0xA3C1)=0` but `iswupper_l(0xFF21)=1`,
+ and `mbtowc()` of the EUC-JP bytes `A3 C1` yields `wchar_t 0xFF21`.)
+
+ A correct fix must bridge that gap — e.g. convert the `pg_wchar` back through
+ the database encoding to a libc `wchar_t` before calling `iswupper_l`/
+ `iswlower_l` (the path `char2wchar()`/`strlower_libc_mb()` already take for
+ case *mapping*), or teach `like_fixed_prefix_ci()` to distrust
`pg_iswcased()`
+ for these encodings and decline the case-sensitive prefix. Because the
+ precise remedy is a design choice, **the test deliberately asserts only the
+ required end state** (see below) and is agnostic to which remedy is chosen.
+
+* **User-visible symptom.** In a libc EUC_JP (or EUC_CN/EUC_KR/EUC_TW/…)
+ database with a case-mapping locale, a query such as
+ `SELECT w FROM t WHERE w ILIKE 'A%'` returns **different rows depending on
the
+ plan**: a sequential scan returns both `A…` and `a…` rows, but a
+ `text_pattern_ops` index scan returns only the `A…` row. No error is raised;
+ the answer is simply, silently wrong, and depends on cost estimates and GUCs.
+
+* **How the test detects it.** `t/001_ilike_eucjp.pl` `initdb`s a libc EUC_JP
+ cluster with `--lc-ctype=ja_JP.eucjp`, builds a table with a fullwidth-`A`
+ row, a fullwidth-`a` row and 100 filler rows, and a `text_pattern_ops` index.
+ It then runs the identical `w ILIKE 'A%'` query twice — once with
+ `enable_indexscan=off, enable_bitmapscan=off` (forced sequential scan) and
once
+ with `enable_seqscan=off` (forced index scan) — and asserts:
+ 1. a **plan-independence oracle**: the two result sets are equal
+ (`EXCEPT` count = 0, and the row counts match); and
+ 2. the **correct absolute answer**: both scans return *both* the uppercase
and
+ lowercase fullwidth rows.
+ On master the forced-index arm returns 1 row and the forced-seq arm returns
2,
+ so the oracle fails with `index scan returned 1 rows, seq scan returned 2
rows`
+ and the absolute-answer checks fail (`idx_has_lower = 0`). The two-part
design
+ means the test cannot be satisfied by a "both paths equally wrong" change
+ (e.g. the insufficient `wc_iscased_libc_mb` swap above, under which both the
+ plan-independence oracle *and* the absolute-answer check remain red). The
+ EUC_JP byte sequences are generated in Perl as pure-ASCII `E'\xa3\xc1'`-style
+ escapes, so the test is independent of the shell's and the file's own
+ encoding.
+
+ The test guards every precondition and **skips** (never hard-fails) on:
+ Windows (`$windows_os`); a missing OS locale (it probes the glibc spellings
+ `ja_JP.eucjp`, `ja_JP.eucJP`, `ja_JP.EUC-JP`, `ja_JP.ujis` via `setlocale`);
+ an `initdb` that refuses the encoding/locale (a trial `initdb` into a
tempdir);
+ and a locale that does not actually case-map the test character
+ (`SELECT lower(A) = a`, which exercises the independent `strlower_libc_mb`
+ path, so it cannot mask the `pg_iswcased` bug). EUC_KR and EUC_CN arms are
+ included and skip individually when their locales are absent.
+
+**Test home — justification.** A plain `pg_regress` test cannot reach this
bug:
+the database encoding is fixed at `initdb` time, and the only encoding an
+ordinary regression run can assume is the build default (typically UTF8), which
+is *not* affected. Reproducing it requires an `initdb --encoding=EUC_JP
+--locale-provider=libc --lc-ctype=ja_JP.eucjp` cluster, which needs
+`PostgreSQL::Test::Cluster`. A brand-new module
+`src/test/modules/test_ilike_libc_mb/` was chosen (over grafting onto an
+existing suite) because no existing suite is about libc multibyte ctype
+behavior, the new directory is the natural home for this report next to its
+test, and it keeps the locale-gated, encoding-specific setup self-contained.
It
+is registered for both build systems: `src/test/modules/meson.build` and
+`src/test/modules/Makefile`, plus the module's own `meson.build` and `Makefile`
+(`TAP_TESTS = 1`).
+
+---
+
+## Defects found but NOT covered by a test
+
+**None.** Triage classified exactly one qualifying user-visible defect for
this
+commit (T1 above), and it is fully testable in-tree, so there is no REPORT-ONLY
+defect. The candidates that were investigated and set aside are negative
+results, documented in the next section rather than here.
+
+---
+
+## Candidates examined and rejected
+
+* **X1 — "It is deliberate design; the single-byte classifier is
intentional."**
+ One hunter (robustness angle) argued that the comment above
+ `ctype_methods_libc_other_mb` — *"Non-UTF8 multibyte encodings use multibyte
+ semantics for case mapping, but single-byte semantics for pattern matching"*
+ (`pg_locale_libc.c:417-418`) — sanctions the single-byte `wc_iscased`.
+ **Rejected.** That comment governs the *symmetric* regex character-class
+ classifiers (`wc_isalpha`, `wc_isupper`, …), where the same single-byte
+ decision is applied on both the index side and the recheck side, so no wrong
+ results can arise. `wc_iscased` is different: it is consumed
*asymmetrically*
+ by `like_fixed_prefix_ci()` to build a case-**sensitive** index bound that
the
+ executor's `ILIKE` recheck does **not** reproduce. A "single-byte" answer
here
+ is not a documented semantics choice, it is a plan-dependent wrong result.
+ Triage re-reproduced `seq=2 vs idx=1` to confirm.
+
+* **X2 — The other `_sb` entries in `ctype_methods_libc_other_mb`
+ (`wc_isalpha`, `wc_isupper`, `wc_islower`, `wc_isdigit`, `wc_isspace`, …).**
+ These are also single-byte, and are equally unable to classify multibyte
+ characters. **Rejected as not attributable and not wrong-results.** They
+ predate 630706c (they were not touched by it), and, unlike `wc_iscased`, they
+ feed the *symmetric* regex path where both scan sides agree — so there is no
+ user-visible wrong answer to attribute.
+
+* **X3 — UTF8 `pg_iswcased()` mis-classifies characters (ß, İ, ff, final σ,
…).**
+ **Rejected.** For UTF8, `pg_wchar` equals the Unicode code point and the
UTF8
+ table correctly uses `wc_iscased_libc_mb`; libc, ICU and the builtin provider
+ were checked and all return `seq = idx = 2` with no case-sensitive prefix
+ range. UTF8 databases are unaffected.
+
+* **X4 — `~*` (regex case-insensitive), `citext`, and plain `LIKE`.**
+ **Rejected — not affected.** `~*` extracts its prefix via
+ `regex_fixed_prefix()`, which does not call `pg_iswcased()`; `citext ILIKE`
+ extracts no prefix range (filter-only); plain case-sensitive `LIKE` uses
+ `like_fixed_prefix()`, which never calls `pg_iswcased()`.
+
+* **X5 — NULL-pointer / crash paths in the new API.** **Rejected — not
+ user-visible** as a distinct defect; no reachable crash was reproduced.
+
+**Follow-up commits between 630706c and master on the touched files**
+(`git log --oneline 630706c..master -- src/backend/utils/adt/pg_locale_libc.c
+src/backend/utils/adt/like_support.c src/include/utils/pg_locale.h`), none of
+which fixes T1:
+
+```
+cc33580 Fix like_fixed_prefix_ci() selectivity.
+e615da8 Fix for loop variables
+e6e08dc pg_locale_libc.c: add missing casts to unsigned char.
+dbbeafe pg_locale_libc.c: add guards to ctype methods.
+3ab2abc Fix obsolete comment.
+2d7808e Fix LIKE/regex optimization for indexscan with exact-match pattern.
+6d22c67 Don't accept length of -1 in pg_locale.h APIs.
+b2869eb Fix integer-overflow and alignment hazards in locale-related code.
+de90bb7 Fix theoretical memory leaks in pg_locale_libc.c.
+af2d4ca Clean up ICU includes.
+c4ff35f ICU: use UTF8-optimized case conversion API
+451c439 Update copyright for 2026
+0a90df5 Avoid global LC_CTYPE dependency in pg_locale_icu.c.
+87b2968 downcase_identifier(): use method table from locale provider.
+24bf379 Clarify a #define introduced in 8d299052fe.
+54c41a6 Remove unused single-byte char_is_cased() API.
+9c8de15 Use multibyte-aware extraction of pattern prefixes.
+```
+
+`dbbeafe` added the guard and `9c8de15` added the consumer (both discussed
under
+T1); `cc33580` and `2d7808e` adjust `like_fixed_prefix_ci()` selectivity and an
+exact-match case but leave the mis-classification intact. The bug is verified
+still live on master by the accompanying test.
+
+---
+
+## How to reproduce
+
+### With the accompanying test (watch it fail on master)
+
+```bash
+cd /home/nm/src/pg/wt-630706c
+ninja -C build
+meson test -C build --suite setup --suite test_ilike_libc_mb --print-errorlogs
+```
+
+Expected on unmodified master (3 of 21 assertions fail; EUC_KR/EUC_CN skip):
+
+```
+not ok 5 - EUC_JP: indexed ILIKE returns the uppercase and lowercase rows
+not ok 6 - EUC_JP: indexed ILIKE drops no row that the sequential scan returns
+not ok 7 - EUC_JP: indexed and sequential ILIKE agree (index scan returned 1
rows, seq scan returned 2 rows)
+```
+
+### By hand (manual SQL)
+
+```bash
+export PATH=/home/nm/src/pg/wt-630706c/inst/bin:$PATH
+export LD_LIBRARY_PATH=/home/nm/src/pg/wt-630706c/inst/lib
+D=$(mktemp -d /tmp/pgdata-XXXXXX)
+initdb -D "$D/data" -U postgres --locale-provider=libc \
+ --encoding=EUC_JP --lc-collate=ja_JP.eucjp --lc-ctype=ja_JP.eucjp
>/dev/null
+pg_ctl -D "$D/data" -l "$D/log" -o "-p 6370 -k $D" start
+psql -h "$D" -p 6370 -U postgres -d postgres <<'SQL'
+CREATE TABLE t (w text);
+INSERT INTO t VALUES (E'\xa3\xc1'), (E'\xa3\xe1'); -- fullwidth A, a
+INSERT INTO t SELECT md5(g::text) FROM generate_series(1,100) g;
+CREATE INDEX t_tpo ON t (w text_pattern_ops);
+ANALYZE t;
+SET enable_indexscan=off; SET enable_bitmapscan=off; SET enable_seqscan=on;
+SELECT count(*) AS seq_rows FROM t WHERE w ILIKE E'\xa3\xc1%'; -- expect 2
+SET enable_indexscan=on; SET enable_bitmapscan=on; SET enable_seqscan=off;
+SELECT count(*) AS idx_rows FROM t WHERE w ILIKE E'\xa3\xc1%'; -- master: 1
(WRONG)
+SQL
+pg_ctl -D "$D/data" stop -m immediate
+rm -rf "$D"
+```
+
+On master `seq_rows = 2` but `idx_rows = 1`: the indexed `ILIKE` silently drops
+the lowercase fullwidth row.
diff --git a/src/test/modules/test_ilike_libc_mb/Makefile
b/src/test/modules/test_ilike_libc_mb/Makefile
new file mode 100644
index 0000000..bb1795f
--- /dev/null
+++ b/src/test/modules/test_ilike_libc_mb/Makefile
@@ -0,0 +1,16 @@
+# src/test/modules/test_ilike_libc_mb/Makefile
+
+PGFILEDESC = "test_ilike_libc_mb - ILIKE index prefixes in libc multibyte DBs"
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_ilike_libc_mb
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_ilike_libc_mb/meson.build
b/src/test/modules/test_ilike_libc_mb/meson.build
new file mode 100644
index 0000000..f845649
--- /dev/null
+++ b/src/test/modules/test_ilike_libc_mb/meson.build
@@ -0,0 +1,12 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+tests += {
+ 'name': 'test_ilike_libc_mb',
+ 'sd': meson.current_source_dir(),
+ 'bd': meson.current_build_dir(),
+ 'tap': {
+ 'tests': [
+ 't/001_ilike_eucjp.pl',
+ ],
+ },
+}
diff --git a/src/test/modules/test_ilike_libc_mb/t/001_ilike_eucjp.pl
b/src/test/modules/test_ilike_libc_mb/t/001_ilike_eucjp.pl
new file mode 100644
index 0000000..fed4381
--- /dev/null
+++ b/src/test/modules/test_ilike_libc_mb/t/001_ilike_eucjp.pl
@@ -0,0 +1,239 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+# Regression test for pg_iswcased() on libc non-UTF8 multibyte encodings.
+#
+# Commit 630706ced0 ("Add pg_iswcased().") wired the single-byte classifier
+# wc_iscased_libc_sb into ctype_methods_libc_other_mb, the ctype method table
+# used for libc databases whose encoding is a non-UTF8 multibyte encoding
+# (EUC_JP, EUC_KR, EUC_CN, EUC_TW, EUC_JIS_2004, MULE_INTERNAL). Together with
+# the "if (wc > UCHAR_MAX) return false;" guard later added by dbbeafe61a, this
+# makes pg_iswcased() report *every* multibyte character as "not cased".
+#
+# like_fixed_prefix_ci() (like_support.c) trusts pg_iswcased() when it extracts
+# the fixed, case-invariant prefix of an ILIKE pattern for use as a B-tree
index
+# range qual. When a genuinely cased multibyte letter is wrongly reported as
+# uncased, the letter is filed into a case-sensitive prefix range, so an
indexed
+# ILIKE silently returns fewer rows than a sequential scan of the same table.
+#
+# We assert two things that must hold on a correct server, independent of any
+# platform-specific expected output:
+# * the same ILIKE query returns identical row sets under a forced sequential
+# scan and under a forced index scan (plan independence), and
+# * both scans return both the uppercase- and lowercase-fullwidth rows (the
+# correct absolute answer).
+# On unpatched master the index scan drops the lowercase row and the test
fails.
+# A correct server returns identical, complete row sets from both scans. Note
+# that the fix is subtler than the sibling method tables suggest: merely
swapping
+# in wc_iscased_libc_mb (the classifier used by the UTF8 table) does NOT help,
+# because for a non-UTF8 multibyte encoding PostgreSQL's pg_wchar is the packed
+# encoded bytes (fullwidth A is 0xA3C1), not a libc wchar_t (glibc yields
0xFF21),
+# so iswupper_l()/iswlower_l() still misclassify it. A correct fix must bridge
+# that representation gap, or teach like_fixed_prefix_ci() to distrust the
result
+# for these encodings. This test asserts the required end state and is
agnostic
+# to which of those a fix chooses.
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+use POSIX qw(setlocale LC_CTYPE);
+
+# This test needs an OS locale that case-maps multibyte letters in a non-UTF8
+# multibyte encoding. Such locales never exist on Windows.
+if ($windows_os)
+{
+ plan skip_all => "libc EUC_* locales are not available on Windows";
+}
+
+# Encodings to probe. Each entry names a PostgreSQL server encoding, a list of
+# plausible OS-locale spellings (glibc spells the EUC-JP locale several ways),
+# and the encoding bytes of a cased letter pair: a fullwidth Latin capital
+# letter and the small letter it lower-cases to. All three encodings place the
+# fullwidth ASCII block in row 3, so fullwidth "A"/"a" are 0xA3C1/0xA3E1.
+my @cases = (
+ {
+ name => 'eucjp',
+ encoding => 'EUC_JP',
+ locales => [qw(ja_JP.eucjp ja_JP.eucJP ja_JP.EUC-JP
ja_JP.ujis)],
+ upper => 'a3c1', # U+FF21 FULLWIDTH LATIN CAPITAL LETTER A
+ lower => 'a3e1', # U+FF41 FULLWIDTH LATIN SMALL LETTER A
+ },
+ {
+ name => 'euckr',
+ encoding => 'EUC_KR',
+ locales => [qw(ko_KR.euckr ko_KR.eucKR ko_KR.EUC-KR)],
+ upper => 'a3c1',
+ lower => 'a3e1',
+ },
+ {
+ name => 'euccn',
+ encoding => 'EUC_CN',
+ locales => [qw(zh_CN.gb2312 zh_CN.GB2312 zh_CN.eucCN
zh_CN.euccn)],
+ upper => 'a3c1',
+ lower => 'a3e1',
+ },
+);
+
+# Build a PostgreSQL string literal of the given hex bytes using \x escapes,
+# e.g. "a3c1" => E'\xa3\xc1'. The escapes are pure ASCII in the query text, so
+# the literal is independent of the client encoding and of this file's own
+# encoding; the server materializes the exact bytes and validates them against
+# the database encoding.
+sub byte_literal
+{
+ my ($hex, $suffix) = @_;
+ $suffix = '' unless defined $suffix;
+ (my $escaped = $hex) =~ s/(..)/\\x$1/g;
+ return "E'$escaped$suffix'";
+}
+
+# Return the first candidate OS locale that the C library actually has, or
+# undef. The process LC_CTYPE is saved and restored around the probe.
+sub probe_locale
+{
+ my ($candidates) = @_;
+ my $saved = setlocale(LC_CTYPE);
+ my $found;
+ foreach my $cand (@$candidates)
+ {
+ if (defined setlocale(LC_CTYPE, $cand))
+ {
+ $found = $cand;
+ last;
+ }
+ }
+ setlocale(LC_CTYPE, $saved) if defined $saved;
+ return $found;
+}
+
+foreach my $case (@cases)
+{
+ my $enc = $case->{encoding};
+ my $U = byte_literal($case->{upper});
+ my $L = byte_literal($case->{lower});
+ my $Upat = byte_literal($case->{upper}, '%');
+
+ # Each encoding contributes this many test points when it runs; the same
+ # count is emitted as skips when a precondition is not met.
+ my $ntests = 7;
+
+ SKIP:
+ {
+ # Skip this encoding unless its OS locale is installed here.
+ my $locale = probe_locale($case->{locales});
+ skip "no OS locale for $enc installed on this system", $ntests
+ unless defined $locale;
+
+ # Skip if initdb refuses this encoding/locale for any reason,
rather
+ # than failing the whole test file.
+ my $trial = PostgreSQL::Test::Utils::tempdir();
+ my $initdb_ok = run_log(
+ [
+ 'initdb', '--no-sync',
+ '--auth' => 'trust',
+ '--pgdata' => "$trial/data",
+ '--locale-provider=libc',
+ "--encoding=$enc",
+ "--lc-collate=$locale",
+ "--lc-ctype=$locale",
+ ]);
+ skip "initdb with $enc/$locale failed on this system", $ntests
+ unless $initdb_ok;
+
+ my $node = PostgreSQL::Test::Cluster->new($case->{name});
+ $node->init(
+ extra => [
+ '--locale-provider=libc',
+ "--encoding=$enc",
+ "--lc-collate=$locale",
+ "--lc-ctype=$locale",
+ ]);
+ $node->start;
+
+ # The bug can only manifest where the locale actually case-maps
the
+ # chosen letter. If it does not, there is no cased character to
+ # mis-file, so skip rather than emit a meaningless result.
+ my $is_cased =
+ $node->safe_psql('postgres', "SELECT lower($U) = $L AND $U <>
$L");
+ unless ($is_cased eq 't')
+ {
+ $node->stop('immediate');
+ skip "locale $locale does not case-map the $enc test
character",
+ $ntests;
+ }
+
+ $node->safe_psql(
+ 'postgres', qq{
+ CREATE TABLE t (w text);
+ INSERT INTO t VALUES ($U), ($L);
+ -- filler so an index scan is unambiguously viable
+ INSERT INTO t SELECT md5(g::text) FROM
generate_series(1, 100) g;
+ CREATE INDEX t_tpo ON t (w text_pattern_ops);
+ ANALYZE t;
+ });
+
+ # Guard: the forced-index arm of the oracle must really use the
index,
+ # otherwise the oracle could pass vacuously by comparing a
sequential
+ # scan against itself on a buggy server.
+ my $plan = $node->safe_psql(
+ 'postgres', qq{
+ SET enable_seqscan = off;
+ SET enable_indexscan = on;
+ SET enable_bitmapscan = on;
+ EXPLAIN (COSTS OFF) SELECT w FROM t WHERE w ILIKE $Upat;
+ });
+ like(
+ $plan,
+ qr/Index (Only )?Scan|Bitmap Index Scan/,
+ "$enc: forced-index ILIKE plan uses the index");
+ unlike($plan, qr/Seq Scan/,
+ "$enc: forced-index ILIKE plan is not a sequential
scan");
+
+ # Oracle: run the identical ILIKE once as a forced sequential
scan and
+ # once as a forced index scan, and compare the row sets.
+ my $res = $node->safe_psql(
+ 'postgres', qq{
+ SET enable_indexscan = off;
+ SET enable_bitmapscan = off;
+ SET enable_seqscan = on;
+ CREATE TEMP TABLE r_seq AS SELECT w FROM t WHERE w
ILIKE $Upat;
+ SET enable_indexscan = on;
+ SET enable_bitmapscan = on;
+ SET enable_seqscan = off;
+ CREATE TEMP TABLE r_idx AS SELECT w FROM t WHERE w
ILIKE $Upat;
+ SELECT (SELECT count(*) FROM r_seq),
+ (SELECT count(*) FROM r_idx),
+ (SELECT count(*) FROM r_seq WHERE w = $U),
+ (SELECT count(*) FROM r_seq WHERE w = $L),
+ (SELECT count(*) FROM r_idx WHERE w = $U),
+ (SELECT count(*) FROM r_idx WHERE w = $L),
+ (SELECT count(*) FROM
+ (SELECT w FROM r_seq EXCEPT SELECT w FROM
r_idx) d);
+ });
+ my ($seq_n, $idx_n, $seq_u, $seq_l, $idx_u, $idx_l, $missing) =
+ split /\|/, $res;
+
+ # Correct absolute answer: both scans must return both
fullwidth rows.
+ is($seq_n, 2, "$enc: sequential ILIKE returns both fullwidth
rows");
+ is("$seq_u$seq_l", "11",
+ "$enc: sequential ILIKE returns the uppercase and
lowercase rows");
+ is("$idx_u$idx_l", "11",
+ "$enc: indexed ILIKE returns the uppercase and
lowercase rows");
+
+ # Plan independence: the index scan must not drop rows the seq
scan
+ # returns, and the two counts must match.
+ is($missing, 0,
+ "$enc: indexed ILIKE drops no row that the sequential
scan returns"
+ );
+ is($idx_n, $seq_n,
+ "$enc: indexed and sequential ILIKE agree "
+ . "(index scan returned $idx_n rows, seq scan
returned $seq_n rows)"
+ );
+
+ $node->stop('immediate');
+ }
+}
+
+done_testing();