From: Junio C Hamano <gitster@pobox.com>
To: Chungmin Lee <chungmin@chungminlee.com>
Cc: git@vger.kernel.org
Subject: Re: [PATCH] regexec: work around macOS TRE memory leak on invalid UTF-8
Date: Thu, 23 Jul 2026 21:19:57 -0700 [thread overview]
Message-ID: <xmqqpl0d9fyq.fsf@gitster.g> (raw)
In-Reply-To: <20260722053127.37244-1-chungmin@chungminlee.com> (Chungmin Lee's message of "Tue, 21 Jul 2026 22:31:27 -0700")
Chungmin Lee <chungmin@chungminlee.com> writes:
> This came out of a real incident: "git grep -i" over a repository that
> contains PDFs exhausted memory on an otherwise idle Mac mini and took the
> machine down with a kernel watchdog panic ("no checkins from watchdogd").
> The leak is in the system regex engine, not in git, but git is what
> drives it into the leaking path, once per line.
>
> Why this belongs in regexec_buf():
Why does this belong to Git, not macOS, in the first place?
> diff --git a/compat/regexec.c b/compat/regexec.c
> new file mode 100644
> index 000000000..0677162a8
> --- /dev/null
> +++ b/compat/regexec.c
> @@ -0,0 +1,108 @@
> +#include "git-compat-util.h"
> +
> +#ifdef REGEXEC_MAY_LEAK_ON_ILLSEQ
> +
> +#include <wchar.h>
> +
> +/*
> + * macOS's libc regex engine (TRE) leaks the buffer it allocates for a
> + * match whenever regexec() encounters an invalid multibyte sequence in
> + * a multibyte locale: it returns REG_ILLSEQ without freeing that buffer.
> + * A single "git grep" over a file with binary data can call regexec()
> + * once per line and leak gigabytes, which has been observed to exhaust
> + * memory and trigger a kernel watchdog panic.
> + *
> + * The leak happens inside regexec() before it returns, so reacting to
> + * REG_ILLSEQ cannot avoid it: the invalid bytes must never reach the
> + * matcher. Split the buffer at each invalid sequence and search the
> + * surrounding runs of valid text separately. A match on either side of
> + * the invalid bytes is still found (the same result the matcher gives on
> + * valid input), but the leaking REG_ILLSEQ path is never reached.
> + *
> + * Use mbrtowc() to decide where to split, so that we split at exactly the
> + * bytes the platform's own decoder -- and thus the regex engine, which
> + * decodes the same way -- rejects. A hand-rolled validator would
> + * have to guess that boundary; being too lenient reintroduces the leak.
> + */
This clearly seems to be a workaround for a platform bug. Do we
know how long we will need to keep it?
> +/*
> + * Search buf[start, end) for a match. REG_STARTEND reports offsets
> + * relative to buf, so a hit needs no translation. ^ may only match at
> + * the real start of the buffer and $ only at its real end, so suppress
> + * them when this segment does not reach those boundaries.
> + */
> +static int regexec_segment(const regex_t *preg, const char *buf,
> + size_t start, size_t end, size_t size,
> + size_t nmatch, regmatch_t pmatch[], int eflags)
> +{
> + eflags |= REG_STARTEND;
> + if (start > 0)
> + eflags |= REG_NOTBOL;
> + if (end < size)
> + eflags |= REG_NOTEOL;
> + pmatch[0].rm_so = start;
> + pmatch[0].rm_eo = end;
> + return regexec(preg, buf, nmatch, pmatch, eflags);
> +}
> +
> +int regexec_buf(const regex_t *preg, const char *buf, size_t size,
> + size_t nmatch, regmatch_t pmatch[], int eflags)
> +{
> + size_t seg_start = 0, i = 0;
> + mbstate_t mbs;
> +
> + assert(nmatch > 0 && pmatch);
> +
> + /*
> + * Only a multibyte locale drives TRE through the leaking multibyte
> + * path. In a single-byte locale (MB_CUR_MAX == 1) no byte is
> + * invalid, so search the whole buffer as before. MB_CUR_MAX
> + * reflects the current LC_CTYPE, the same locale mbrtowc() below
> + * decodes against.
> + */
> + if (MB_CUR_MAX == 1) {
> + pmatch[0].rm_so = 0;
> + pmatch[0].rm_eo = size;
> + return regexec(preg, buf, nmatch, pmatch, eflags | REG_STARTEND);
> + }
> +
> + memset(&mbs, 0, sizeof(mbs));
> + while (i < size) {
> + unsigned char c = (unsigned char)buf[i];
> + size_t n;
> +
> + if (c < 0x80) { /* ASCII fast path */
> + i++;
> + continue;
> + }
> +
> + n = mbrtowc(NULL, buf + i, size - i, &mbs);
> + if (!n) /* embedded NUL decodes to one byte */
> + n = 1;
> + if (n != (size_t)-1 && n != (size_t)-2) {
> + i += n;
> + continue;
> + }
OK. I wonder if we want to document what -1 and -2 signify (in
other words, why we stop only when the call returns one of these
two values), or is it too obvious for users of mbrtowc()?
In any case, if control reaches here, we saw either an invalid
sequence (-1) or not enough bytes to complete a whole multi-byte
character (-2), i.e., the case where regexec() would have trouble
matching starting at offset 'i'. The bytes before that position
make an OK substring.
> + /* buf[i] begins an invalid sequence; search the run before it */
> + if (i > seg_start) {
> + int ret = regexec_segment(preg, buf, seg_start, i, size,
> + nmatch, pmatch, eflags);
> + if (ret != REG_NOMATCH)
> + return ret;
> + }
Naturally, this "check the OK prefix string" approach makes readers
wonder what happens when the pattern is "right anchored$" and the OK
prefix would match if the string truly ended at 'i' (or, if this is a
second or subsequent segment, the pattern is "^left anchored", and
the segment would match if the string started at 'seg_start'). The
use of 'REG_STARTEND' in regexec_segment() above, combined with
'REG_NOTBOL'/'REG_NOTEOL', is a clever way to work around it cleanly.
> diff --git a/git-compat-util.h b/git-compat-util.h
> index 880977640..3861c9353 100644
> --- a/git-compat-util.h
> +++ b/git-compat-util.h
> @@ -992,6 +992,10 @@ static inline int strtol_i(char const *s, int base, int *result)
> #error "Git requires REG_STARTEND support. Compile with NO_REGEX=NeedsStartEnd"
> #endif
>
> +#ifdef REGEXEC_MAY_LEAK_ON_ILLSEQ
Hmph, how many different symbols do we need to deal with this? The
Makefile has DARWIN_TRE_REGEXEC_LEAK_WORKAROUND and CPP macro is
REGEXEC_MAY_LEAK_ON_ILLSEQ?
> +int regexec_buf(const regex_t *preg, const char *buf, size_t size,
> + size_t nmatch, regmatch_t pmatch[], int eflags);
> +#else
> static inline int regexec_buf(const regex_t *preg, const char *buf, size_t size,
> size_t nmatch, regmatch_t pmatch[], int eflags)
> {
> @@ -1000,6 +1004,7 @@ static inline int regexec_buf(const regex_t *preg, const char *buf, size_t size,
> pmatch[0].rm_eo = size;
> return regexec(preg, buf, nmatch, pmatch, eflags | REG_STARTEND);
> }
> +#endif
It is a bit awkward that the next platform needing its own
implementation of regexec_buf() to work around a different platform
bug would have to do:
#if defined(REGEXEC_MAY_LEAK_ON_ILLSEQ) || defined(SOME_OTHER_PLATFORM_BUG)
int regexec_buf(.....);
#else
static inline int regexec_buf(.....)
... the current definition comes here ...
#endif
I thought it was more common to:
* Have each platform with such a need define an override in its own
platform header file:
int darwin_regexec_buf(.....);
#define regexec_buf darwin_regexec_buf
* Have a header file like 'git-compat-util.h' include such a header
file (conditionally on relevant platforms, of course); and
* Have the common header file do this:
#ifndef regexec_buf
static inline int regexec_buf(.....)
... the current definition comes here ...
#endif
Right now, macOS is the only platform that needs an override, so the
result would be about the same amount of code. However, in the long
run, this structure may give us a better organization, no?
> diff --git a/t/t7810-grep.sh b/t/t7810-grep.sh
> ...
> +test_expect_success MACOS,MB_REGEX 'grep anchors ^ and $ at true line ends past invalid UTF-8' '
Do we need to allow this test to fail on non macOS hosts? Why?
> + LC_ALL=en_US.UTF-8 git grep -h "^before" invalid-utf8-embedded >actual &&
> + test_cmp invalid-utf8-embedded actual &&
> + LC_ALL=en_US.UTF-8 git grep -h "world\$" invalid-utf8-embedded >actual &&
> + test_cmp invalid-utf8-embedded actual
> +'
> +
Thanks.
next prev parent reply other threads:[~2026-07-24 4:20 UTC|newest]
Thread overview: 4+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-22 5:31 [PATCH] regexec: work around macOS TRE memory leak on invalid UTF-8 Chungmin Lee
2026-07-24 4:19 ` Junio C Hamano [this message]
2026-07-28 5:25 ` [PATCH v2] regexec: work around macOS TRE " Chungmin Lee
2026-07-29 0:41 ` Junio C Hamano
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=xmqqpl0d9fyq.fsf@gitster.g \
--to=gitster@pobox.com \
--cc=chungmin@chungminlee.com \
--cc=git@vger.kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox