Reduce memory footprint for large inputs, harden allocation failure paths
Profiled a real search with Massif and found the memory-per-input-byte multiplier at 13.4x, dominated by two avoidable costs: - build_matbuf widened every BINARY/ASCII byte into a uint32_t before matching, a 4x copy that mode never needed (a byte never exceeds 255). Removed it: MCtx/MatBuf now carry an optional text8 (borrowed, unwidened) alongside the existing UTF8 text (owned, decoded code points), reconciled per-read through text_at()/buf_at(). BINARY/ ASCII mode now points directly into the Input's own buffer. - Input_from_file always copied the whole file into a malloc'd buffer. It now mmaps regular files read-only (MAP_PRIVATE) instead, so pages stay clean and reclaimable under memory pressure and the file is never copied. Non-seekable sources (pipes, FIFOs, process substitution, stdin) and mmap failures fall back to the previous incremental-read behavior via a separate read_fd_incrementally. Together these bring the multiplier to 8.4x and let a 200MB file that previously OOM-crashed complete a full non-matching search in about 6 seconds at roughly 1.69GB peak RSS. Also tried, measured, and reverted: capping compute_maxrun/ compute_next_prevmatch's table size with a plain-scan fallback above the cap. A real 200MB non-matching search against this fallback hung for minutes instead of failing fast, because disabling either table reintroduces the O(n^2) behavior they exist to prevent, and O(n^2) at n in the hundreds of millions is not practically finite. A fast, diagnosable allocation failure is a better failure mode than a silent, unbounded hang, so the tables are allocated unconditionally again; the finding is recorded in code comments, concept.md 7.6, and README's "Memory footprint" section so it is not retried blindly later. Separately audited every allocation on an input-proportional path (da_push, build_matbuf's UTF-8 decode loop, all three Input_from_file sites) and made each fail cleanly through PatternError instead of crashing on an unchecked NULL dereference. A 1GB file still exceeds available memory in the current environment; this is a property of the machine it was measured on, not a defect, and is documented as such (practical ceiling: available memory / 8.4 for search-family operations, pending the streaming automaton design in concept.md 7.2). Verified with four clean `make test` passes (3252/3252) and a clean ASan/UBSan pass after the change; rxgrep's mmap-backed paths (--sub with a regular file, with a non-seekable process-substitution source, and BINARY-mode embedded NUL handling) re-checked directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EjuMk8kY9SDus1wWe2K9xY
This commit is contained in:
@@ -24,11 +24,13 @@ of the input.
|
||||
|
||||
**It does not yet implement the streaming, bounded-memory regular engine of
|
||||
`concept.md` Section 7.2.** `Input` (the abstraction over "a source of
|
||||
chunks", `concept.md` 9.2) is implemented, and `Input_from_file` reads a
|
||||
whole file into memory before matching. Every public function signature is
|
||||
already exactly what the streaming design in `concept.md` specifies, so the
|
||||
non-streaming implementation underneath a given call can be replaced later
|
||||
without changing any caller. Concretely, today:
|
||||
chunks", `concept.md` 9.2) is implemented, and `Input_from_file` maps or
|
||||
reads a whole file into memory before matching (see "Memory footprint"
|
||||
below for exactly how, and for the measured numbers this and other fixes
|
||||
were checked against). Every public function signature is already exactly
|
||||
what the streaming design in `concept.md` specifies, so the non-streaming
|
||||
implementation underneath a given call can be replaced later without
|
||||
changing any caller. Concretely, today:
|
||||
|
||||
- `Pattern_match`/`Pattern_fullmatch` (a single anchored attempt at a fixed
|
||||
position) run in time proportional to the length of that attempt, and, for
|
||||
@@ -141,6 +143,85 @@ writeup on ReDoS and catastrophic
|
||||
backtracking](https://snyk.io/blog/redos-and-catastrophic-backtracking/) for
|
||||
the general phenomenon and mitigation guidance.
|
||||
|
||||
### Memory footprint
|
||||
|
||||
Measured directly with Valgrind/Massif (a real 10MB search) and by watching
|
||||
`VmRSS`/`VmHWM` on real 200MB-1GB files, not estimated from reading the
|
||||
code. `Pattern_search`/`finditer`/`split`/`sub` (the operations that try
|
||||
more than one start position) currently use, at peak, about **8.4x** the
|
||||
input length in memory for a pattern using `OP_REPEAT1`
|
||||
(`concept.md` 7.5's `compute_maxrun` and `compute_next_prevmatch` tables,
|
||||
`int32_t`-per-input-position each, are the entire remaining cost: 47.75%
|
||||
each in the Massif profile, `alloc_memo`'s bitset a further 4.5%).
|
||||
`Pattern_match`/`fullmatch` (a single attempt, no search tables) use
|
||||
proportionally less.
|
||||
|
||||
Two real issues were found and fixed getting to that number, in order:
|
||||
|
||||
1. **`build_matbuf` widened every byte to a 4-byte `uint32_t`, even in
|
||||
`BINARY`/`ASCII` mode, where a byte never exceeds 255 and the widening
|
||||
bought nothing.** This cost as much extra memory as the input itself,
|
||||
four times over, unconditionally, on top of the search tables above.
|
||||
Fixed: `BINARY`/`ASCII` mode now reads the input's own bytes directly
|
||||
(`MatBuf`/`MCtx`'s `text8` field, `text_at()`/`buf_at()` in `regexx.c`);
|
||||
only `UTF8` mode still widens, because it actually needs code points up
|
||||
to `0x10FFFF`, which do not fit in a byte. This dropped the measured
|
||||
10MB-search peak from 140.3MB (13.4x) to 87.8MB (8.4x).
|
||||
2. **`Input_from_file` read every file into a fresh, private, `malloc`'d
|
||||
copy, even though the OS's page cache already holds the file's bytes.**
|
||||
For a regular, seekable, non-empty file this now uses `mmap()`
|
||||
(`PROT_READ`, `MAP_PRIVATE`) instead: the mapped pages are backed
|
||||
directly by the file and stay clean (never written), so the kernel can
|
||||
reclaim them under memory pressure and re-fault them in from disk later,
|
||||
rather than them being pinned for the whole match attempt the way a
|
||||
`malloc`'d copy is; it also removes one whole redundant copy of the
|
||||
file's bytes. Falls back to the previous `read()`-based incremental
|
||||
copy for anything `mmap` does not apply to (a pipe, a FIFO, process
|
||||
substitution, stdin, an empty file, or an `mmap()` call that itself
|
||||
fails).
|
||||
|
||||
A **third fix attempt was tried, measured, and reverted** specifically
|
||||
because "prevent OOM, keep the footprint small" turned out to have a
|
||||
sharp edge worth recording: capping `compute_maxrun`/`compute_next_prevmatch`
|
||||
above a size budget and falling back to the plain scan already used when
|
||||
either table is `NULL` seemed like an obvious bounded-memory safety valve.
|
||||
Measured directly against a real 200MB non-matching search, it was worse
|
||||
than doing nothing: both tables are needed together to keep this pattern
|
||||
shape (`x*y`-style, unbounded quantifier followed by a required literal
|
||||
that never occurs) at linear time; disabling either one alone reintroduces
|
||||
the `O(n^2)` behavior they exist to fix, and `O(n^2)` at `n` in the hundreds
|
||||
of millions does not finish in any practical amount of time. A fast,
|
||||
diagnosable allocation failure (see below) is a better failure mode than a
|
||||
silent, effectively-unbounded hang, so the cap was removed; these two
|
||||
tables are allocated unconditionally again. There is no way to get both
|
||||
bounded memory and linear time out of this technique for this pattern
|
||||
shape; only `concept.md` Section 7.2's actual streaming automaton (still
|
||||
unimplemented) gets both at once, by construction, which is why it remains
|
||||
the correct long-term fix for this axis specifically.
|
||||
|
||||
**Failing safely.** Every allocation on the input-proportional paths above
|
||||
(`build_matbuf`, `compute_maxrun`, `compute_next_prevmatch`,
|
||||
`Input_from_file`, the UTF-8 decode arrays) is now checked; a failure
|
||||
returns a `PatternError`/`-1` through the ordinary error path instead of
|
||||
crashing on a `NULL` dereference, which several of them did before this
|
||||
was audited (found by deliberately reasoning through "what happens when
|
||||
this specific `malloc` fails on a huge request", not by a tool). This does
|
||||
not prevent an out-of-memory condition on a genuinely memory-constrained
|
||||
machine; the operating system's OOM killer can still end the process for
|
||||
an allocation this library made in good faith (`malloc`/`mmap` returning
|
||||
`NULL`/`MAP_FAILED` is the case this library can catch; being killed by
|
||||
the kernel before that happens is not something a userspace library can
|
||||
intercept). Measured concretely on the machine this was developed on: a
|
||||
200MB file search that previously crashed via the OOM killer now completes
|
||||
successfully in about 6 seconds at roughly 1.7GB peak RSS; a 1GB file on
|
||||
the same machine still exceeded what was available at the time. Both
|
||||
numbers are specific to that machine's available memory at the time, not
|
||||
a hard property of the library; the 8.4x multiplier above is what actually
|
||||
determines the practical ceiling on a given machine (roughly
|
||||
`available memory / 8.4` for search-family operations on a pattern using
|
||||
`OP_REPEAT1`, more forgiving for `match`/`fullmatch` or for patterns
|
||||
without a simple-atom quantifier at all).
|
||||
|
||||
### Pattern syntax supported
|
||||
|
||||
Literals; `.` (with `DOTALL`); character classes with ranges, negation, and
|
||||
|
||||
+10
-1
@@ -161,9 +161,18 @@ The v1 implementation (README.md "Implementation status") does not yet have Sect
|
||||
|
||||
Together these make the backtracking engine linear, not quadratic, for a pattern with no backreference built from simple repeated atoms (measured: a pattern searched over 80,000 non-matching bytes dropped from 19.2s to 0.0016s), and turn the textbook catastrophic-backtracking shape `(a+)+b` from exponential into empirically quadratic, though not linear, since a repeat over a *compound* body only gets the failure memoization, not the skip-ahead table. Neither technique gives the bounded, input-length-independent *memory* guarantee that is 7.2's actual reason for existing (Section 5): both use `O(instruction count x input length)` memory for their tables, which is bounded but scales with input length, unlike 7.2's `O(instruction count)`. 7.2 therefore remains the correct target for the memory axis of Section 1's objective; what changed is that the *time* axis, for the backreference-free majority of patterns, no longer depends on building it. README.md and `docs/API.md` carry the measured numbers and full citations.
|
||||
|
||||
### 7.6 Implementation finding: profiling the memory axis directly, and a reverted attempt at bounding it
|
||||
|
||||
Measuring 7.5's tables' memory cost directly with Valgrind/Massif, rather than trusting the abstract `O(instruction count x input length)` bound, found it was worse in practice than that bound alone suggested: a real 10MB search peaked at 140.3MB (13.4x the input), and a real 200MB file search was killed by the operating system's OOM killer before finishing. Tracing that measurement to its allocation sites found two contributors 7.5 did not separately account for, both fixed without touching the matcher's correctness at all:
|
||||
|
||||
- `build_matbuf` (Section 8.4/9.3) widened every byte to a 4-byte code point even in `BINARY`/`ASCII` mode, where a byte never exceeds 255 and the widening buys nothing (only `UTF8` mode's actual code points, up to `0x10FFFF`, need it). Fixed by reading `BINARY`/`ASCII` mode's bytes directly instead of widening them first, dropping the measured 10MB case from 140.3MB to 87.8MB (13.4x to 8.4x).
|
||||
- `Input_from_file` (Section 9.2) copied every file into a fresh, private buffer even though the operating system's page cache already holds its bytes. Fixed by using `mmap` for regular, seekable, non-empty files instead: the mapped pages stay clean (read-only, never written) and file-backed, so the kernel can reclaim them under memory pressure and re-fault them in from disk later, rather than them being pinned in a private allocation for the whole match attempt, and one whole redundant copy of the file disappears. `Input_from_file` is the one place this document's dependency-free, single-file design (Section 8) already had to step outside strict ISO C (`fopen`/`fread` are C standard library, but reading a *whole file's size upfront* portably is not); using `mmap`/`open`/`fstat` here is an extension of a dependency this design already carries (POSIX, the same family `wctype.h`'s locale behavior already depends on, Section 13.3), not a new one.
|
||||
|
||||
A third change was attempted, measured, and reverted, which is recorded here because the negative result is as load-bearing as the two fixes above: capping 7.5's two tables above a size budget and falling back to the plain scan already used when either is unavailable seemed like an obvious way to bound memory for huge inputs. Measured directly against a real 200MB non-matching search, it was strictly worse than doing nothing: both tables are needed together to keep an unbounded-quantifier-followed-by-a-required-literal pattern (`x*y`-shaped) at linear time; disabling either one alone reintroduces the `O(n^2)` time they exist to prevent, and `O(n^2)` at `n` in the hundreds of millions does not finish in practice. A fast, diagnosable allocation failure (every allocation on this path is now checked and fails through the ordinary `PatternError`/`-1` path instead of crashing on an unchecked `NULL`, a separate finding from the same audit) is a better failure mode than a silent, effectively-unbounded hang, so the tables are allocated unconditionally again. This is the same conclusion 7.5 already reached from the time axis, now confirmed from the memory axis too: there is no way to get both bounded memory and linear time out of a precomputed-table technique for this pattern shape; only 7.2's actual streaming automaton gets both at once, by construction, which is why it remains the correct long-term answer for this axis specifically rather than a further iteration on tables. README.md "Memory footprint" carries the full measured numbers.
|
||||
|
||||
## 8. Core Data Structures
|
||||
|
||||
Kept intentionally minimal, all defined in the single file, no dependency beyond the C standard library (`stdint.h`, `stddef.h`, `string.h`):
|
||||
Kept intentionally minimal, all defined in the single file, no dependency beyond the C standard library (`stdint.h`, `stddef.h`, `string.h`) for the data structures in this section specifically. `Input_from_file` (Section 9.2) is the one place the file as a whole steps outside strict ISO C, using POSIX (`mmap`/`open`/`fstat`, Section 7.6), already in the same family of platform dependency `wctype.h`'s locale behavior carries (Section 13.3).
|
||||
|
||||
### 8.1 Allocator
|
||||
One struct of three function pointers (`alloc`, `realloc`, `free`) passed once at engine creation, defaulting to the libc equivalents. This is the only piece of "infrastructure" abstraction in the file, and it exists so the sliding window (7.3) and chunk buffers (7.2) can be sized and released under caller control, which is a prerequisite for the gigabyte scale requirement.
|
||||
|
||||
+4
-2
@@ -67,8 +67,8 @@ void Input_free(Input *in);
|
||||
```
|
||||
|
||||
- `Input_from_buffer`: wraps an existing buffer. **Does not copy it and does not take ownership.** The buffer must outlive the `Input` and every `Match` produced from it (`Match_group` returns pointers directly into it, Section 3.9). Freeing an `Input_from_buffer` `Input` never frees the underlying buffer; the caller is responsible for that.
|
||||
- `Input_from_file`: reads the whole file into a freshly allocated, owned buffer. Works on both seekable files and non-seekable sources (pipes, FIFOs, process substitution, `/dev/stdin`): a seekable source is read in one `fread` after `fseek`/`ftell` sizing it; a non-seekable source is read incrementally into a growable buffer. Either way, the entire input ends up in memory before any matching happens (`README.md` "Implementation status"). On failure (cannot open, cannot allocate) returns `NULL` and, if `err` is non-`NULL`, fills it with `strerror(errno)` as `msg`.
|
||||
- `Input_free`: frees the `Input` and, only if it owns its buffer (true for `Input_from_file`, false for `Input_from_buffer`), the buffer too.
|
||||
- `Input_from_file`: for a regular file, `mmap`s it read-only (`MAP_PRIVATE`) instead of copying it into the heap, so the pages are backed by the file and the kernel can reclaim them under memory pressure (`README.md` "Memory footprint"). For a non-seekable source (pipe, FIFO, process substitution, `/dev/stdin`) or when `mmap` itself fails, it falls back to reading incrementally into a growable, owned heap buffer. Either way, the entire input is addressable before any matching happens (`README.md` "Implementation status"); only the second path actually copies it. On failure (cannot open, cannot allocate) returns `NULL` and, if `err` is non-`NULL`, fills it with `strerror(errno)` as `msg`.
|
||||
- `Input_free`: frees the `Input` struct itself always, and additionally releases the underlying buffer unless it came from `Input_from_buffer`: `munmap`s it if it was mapped, or `free`s it if it was read into an owned heap buffer (both cases of `Input_from_file`).
|
||||
|
||||
### 1.4 `PatternError` (`re.error` / `re.PatternError`)
|
||||
|
||||
@@ -147,6 +147,8 @@ Mirror `re.Pattern.match`/`.fullmatch`/`.search` exactly, including the `pos`/`e
|
||||
|
||||
**Performance:** `match`/`fullmatch` do one anchored attempt and cost time proportional to that attempt alone. `search` tries each candidate start position as a separate attempt, but, unlike a naive backtracking search, does not redo the same work at every one: for a backreference-free pattern, `run_memo` caches every proven failure at the (instruction, position) level, and `OP_REPEAT1` (a quantifier over a single character, class, or `.`) additionally uses precomputed run-length and skip-ahead tables so its own internal work is `O(1)` amortized per position rather than `O(remaining length)`. Together these make `search` linear, not quadratic, for the common case (a pattern with no backreference, built from simple repeated atoms). What is not fixed: a repeat over a *compound* body (`(ab)*`) gets the failure-memoization but not the skip-ahead table, so a pathological compound-repeat pattern can still be worse than linear; and any pattern with a backreference disables memoization entirely (unsound there, see Section 5) and can still be worst-case exponential, exactly as in CPython. See README.md "Implementation status" for the measured numbers on both the fixed case and the remaining one, and for citations to the published techniques this uses.
|
||||
|
||||
**Memory:** the tables above cost real, measured memory, not just time complexity: `search`/`finditer`/`split`/`sub` on a pattern using `OP_REPEAT1` peak at roughly 8.4x the input length (measured with Valgrind/Massif; `match`/`fullmatch` do not allocate these tables at all and use proportionally less). README.md "Memory footprint" has the full measured breakdown, including a fix that was tried, measured, and deliberately reverted because it traded a fast allocation failure for an effectively-unbounded hang, which is a worse failure mode, not a better one.
|
||||
|
||||
### 3.5-3.6 `Pattern_finditer` / `Pattern_findall`
|
||||
|
||||
```c
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
#include <wchar.h>
|
||||
#include <locale.h>
|
||||
#include <errno.h>
|
||||
#include <sys/mman.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* ================================================================
|
||||
* 0. Small utilities: dynamic array, allocation helpers
|
||||
@@ -46,11 +50,21 @@ typedef struct { void *data; size_t len, cap, elemsize; } DArr;
|
||||
static void da_init(DArr *a, size_t elemsize) {
|
||||
a->data = NULL; a->len = 0; a->cap = 0; a->elemsize = elemsize;
|
||||
}
|
||||
/* Returns NULL on allocation failure, leaving `a` exactly as it was
|
||||
* (never loses or corrupts the existing elements): most callers in
|
||||
* this file grow bounded-by-pattern-size structures (the AST pool,
|
||||
* class items, the instruction array), where a failure here is
|
||||
* already effectively unreachable in practice and is not separately
|
||||
* checked; the two callers that grow a structure sized to the
|
||||
* *input* (build_matbuf's UTF-8 decode arrays, the only other
|
||||
* uses that can plausibly reach real-world allocation limits) do
|
||||
* check it. */
|
||||
static void *da_push(DArr *a) {
|
||||
if (a->len == a->cap) {
|
||||
size_t nc = a->cap ? a->cap * 2 : 8;
|
||||
a->data = realloc(a->data, nc * a->elemsize);
|
||||
a->cap = nc;
|
||||
void *nd = realloc(a->data, nc * a->elemsize);
|
||||
if (!nd) return NULL;
|
||||
a->data = nd; a->cap = nc;
|
||||
}
|
||||
void *p = (char *)a->data + a->len * a->elemsize;
|
||||
a->len++;
|
||||
@@ -821,9 +835,28 @@ static void compile_node(Prog *pr, Node *n) {
|
||||
|
||||
#define MAX_DEPTH 60000
|
||||
|
||||
/* An earlier revision of this file capped compute_maxrun/
|
||||
* compute_next_prevmatch's table size and fell back to the plain
|
||||
* O(remaining length) scan above the cap, intending a bounded-memory
|
||||
* degradation for huge inputs. Measured directly (a real 200MB search
|
||||
* with no match) and reverted: the fallback does not degrade
|
||||
* gracefully, it reintroduces the O(n^2) behavior these tables exist
|
||||
* to fix, for exactly the pattern shape (a simple-atom quantifier
|
||||
* followed by a required literal/class that never occurs) they matter
|
||||
* most for, and O(n^2) at n in the hundreds of millions does not
|
||||
* finish in any practical amount of time. A fast, diagnosable
|
||||
* allocation failure is a better failure mode than a silent,
|
||||
* effectively-unbounded hang, so these tables are allocated
|
||||
* unconditionally again; README.md "Implementation status" states the
|
||||
* resulting memory-per-input-byte multiplier plainly instead. There is
|
||||
* no way to get both bounded memory and linear time out of this
|
||||
* technique; only concept.md Section 7.2's actual streaming automaton
|
||||
* (still unimplemented) gets both at once, by construction. */
|
||||
|
||||
typedef struct {
|
||||
Prog *pr;
|
||||
const uint32_t *text;
|
||||
const uint32_t *text; /* UTF8 mode: real code points. NULL in BINARY/ASCII mode: use text8/text_at() instead. */
|
||||
const uint8_t *text8; /* BINARY/ASCII mode: raw bytes, no widening copy, see MatBuf.text8 */
|
||||
int64_t len;
|
||||
int64_t *caps; /* 2*(ngroups+1) */
|
||||
int64_t require_end; /* -1 unconstrained, -2 "sub-program, report on OP_RETURN", >=0 exact end required */
|
||||
@@ -898,9 +931,16 @@ static int char_eq(uint32_t a, uint32_t b, int mode, int flags) {
|
||||
if (flags & IGNORECASE) return cls_fold(a, mode, flags) == cls_fold(b, mode, flags);
|
||||
return 0;
|
||||
}
|
||||
/* The one place BINARY/ASCII mode's "no widening copy" (MatBuf.text8,
|
||||
* MCtx.text8) and UTF8 mode's real decoded code points (text) are
|
||||
* reconciled into a single per-position value: c->text is NULL in
|
||||
* BINARY/ASCII mode, so this reads c->text8 instead there. */
|
||||
static inline uint32_t text_at(const MCtx *c, int64_t i) {
|
||||
return c->text ? c->text[i] : c->text8[i];
|
||||
}
|
||||
static int is_word_at(MCtx *c, int64_t pos) {
|
||||
if (pos < 0 || pos >= c->len) return 0;
|
||||
return cls_is_word(c->text[pos], c->pr->mode, c->pr->flags);
|
||||
return cls_is_word(text_at(c, pos), c->pr->mode, c->pr->flags);
|
||||
}
|
||||
|
||||
static int run_memo(MCtx *c, int pc, int64_t sp, int64_t guard_sp, int32_t guard_pc, int depth);
|
||||
@@ -911,16 +951,16 @@ static int run(MCtx *c, int pc, int64_t sp, int64_t guard_sp, int32_t guard_pc,
|
||||
Inst *in = &c->pr->insts[pc];
|
||||
switch (in->op) {
|
||||
case OP_CHAR:
|
||||
if (sp >= c->len || !char_eq(c->text[sp], in->data, c->pr->mode, c->pr->flags)) return 0;
|
||||
if (sp >= c->len || !char_eq(text_at(c, sp), in->data, c->pr->mode, c->pr->flags)) return 0;
|
||||
pc++; sp++; continue;
|
||||
case OP_ANY:
|
||||
if (sp >= c->len) return 0;
|
||||
if (c->text[sp] == '\n' && !(c->pr->flags & DOTALL)) return 0;
|
||||
if (text_at(c, sp) == '\n' && !(c->pr->flags & DOTALL)) return 0;
|
||||
pc++; sp++; continue;
|
||||
case OP_CLASS: {
|
||||
if (sp >= c->len) return 0;
|
||||
Node *cls = ((Node **)c->pr->classnodes.data)[in->data];
|
||||
if (!class_match(cls, c->text[sp], c->pr->mode, c->pr->flags)) return 0;
|
||||
if (!class_match(cls, text_at(c, sp), c->pr->mode, c->pr->flags)) return 0;
|
||||
pc++; sp++; continue;
|
||||
}
|
||||
case OP_REPEAT1: {
|
||||
@@ -939,7 +979,7 @@ static int run(MCtx *c, int pc, int64_t sp, int64_t guard_sp, int32_t guard_pc,
|
||||
count = 0;
|
||||
Node *cls = (in->atomkind == 1) ? ((Node **)c->pr->classnodes.data)[in->data] : NULL;
|
||||
while (count < maxc) {
|
||||
uint32_t ch = c->text[sp + count];
|
||||
uint32_t ch = text_at(c, sp + count);
|
||||
int m;
|
||||
if (in->atomkind == 0) m = char_eq(ch, in->data, c->pr->mode, c->pr->flags);
|
||||
else if (in->atomkind == 2) m = !(ch == '\n' && !(c->pr->flags & DOTALL));
|
||||
@@ -983,8 +1023,8 @@ static int run(MCtx *c, int pc, int64_t sp, int64_t guard_sp, int32_t guard_pc,
|
||||
case OP_ASSERT: {
|
||||
int ok;
|
||||
switch (in->data) {
|
||||
case A_BOL: ok = (sp == 0) || ((c->pr->flags & MULTILINE) && sp > 0 && c->text[sp - 1] == '\n'); break;
|
||||
case A_EOL: ok = (sp == c->len) || (c->text[sp] == '\n' && ((c->pr->flags & MULTILINE) || sp == c->len - 1)); break;
|
||||
case A_BOL: ok = (sp == 0) || ((c->pr->flags & MULTILINE) && sp > 0 && text_at(c, sp - 1) == '\n'); break;
|
||||
case A_EOL: ok = (sp == c->len) || (text_at(c, sp) == '\n' && ((c->pr->flags & MULTILINE) || sp == c->len - 1)); break;
|
||||
case A_BOS: ok = (sp == 0); break;
|
||||
case A_EOS: ok = (sp == c->len); break;
|
||||
case A_WB: ok = is_word_at(c, sp - 1) != is_word_at(c, sp); break;
|
||||
@@ -1020,7 +1060,7 @@ static int run(MCtx *c, int pc, int64_t sp, int64_t guard_sp, int32_t guard_pc,
|
||||
int64_t rl = e - s;
|
||||
if (sp + rl > c->len) return 0;
|
||||
for (int64_t k = 0; k < rl; k++)
|
||||
if (!char_eq(c->text[sp + k], c->text[s + k], c->pr->mode, c->pr->flags)) return 0;
|
||||
if (!char_eq(text_at(c, sp + k), text_at(c, s + k), c->pr->mode, c->pr->flags)) return 0;
|
||||
pc++; sp += rl; continue;
|
||||
}
|
||||
case OP_LOOKAHEAD: {
|
||||
@@ -1109,7 +1149,8 @@ typedef struct {
|
||||
struct Input {
|
||||
uint8_t *buf;
|
||||
size_t len;
|
||||
int owns_buf;
|
||||
int owns_buf; /* free(buf) on Input_free */
|
||||
int is_mmap; /* munmap(buf, len) on Input_free instead of free() */
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
@@ -1281,41 +1322,70 @@ Input *Input_from_buffer(const uint8_t *buf, size_t len) {
|
||||
in->buf = (uint8_t *)buf; in->len = len; in->owns_buf = 0;
|
||||
return in;
|
||||
}
|
||||
Input *Input_from_file(const char *path, PatternError *err) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) { pattern_error_fill(err, NULL, strerror(errno), 0); return NULL; }
|
||||
if (fseek(f, 0, SEEK_END) == 0) {
|
||||
long sz = ftell(f);
|
||||
if (sz >= 0) {
|
||||
rewind(f);
|
||||
uint8_t *buf = malloc((size_t)sz > 0 ? (size_t)sz : 1);
|
||||
size_t got = fread(buf, 1, (size_t)sz, f);
|
||||
fclose(f);
|
||||
Input *in = calloc(1, sizeof(Input));
|
||||
in->buf = buf; in->len = got; in->owns_buf = 1;
|
||||
return in;
|
||||
}
|
||||
}
|
||||
/* Not seekable (a pipe, a FIFO, process substitution, stdin): the
|
||||
* size cannot be known upfront, so read incrementally into a
|
||||
* growable buffer instead. Still materializes the whole input in
|
||||
* memory, per this file's "Implementation status" note. */
|
||||
clearerr(f);
|
||||
/* Reads the rest of an already-open fd (positioned at 0) into a fresh,
|
||||
* owned, growable buffer, used both as Input_from_file's fallback when
|
||||
* mmap is not applicable or fails, and for non-regular files (pipes,
|
||||
* FIFOs, process substitution, stdin) whose size cannot be known
|
||||
* upfront. Takes ownership of fd (always closes it). */
|
||||
static Input *read_fd_incrementally(int fd, PatternError *err) {
|
||||
size_t cap = 1 << 16, len = 0;
|
||||
uint8_t *buf = malloc(cap);
|
||||
size_t n;
|
||||
while ((n = fread(buf + len, 1, cap - len, f)) > 0) {
|
||||
len += n;
|
||||
if (len == cap) { cap *= 2; buf = realloc(buf, cap); }
|
||||
if (!buf) { close(fd); pattern_error_fill(err, NULL, "out of memory reading file", 0); return NULL; }
|
||||
ssize_t n;
|
||||
while ((n = read(fd, buf + len, cap - len)) > 0) {
|
||||
len += (size_t)n;
|
||||
if (len == cap) {
|
||||
size_t ncap = cap * 2;
|
||||
uint8_t *nbuf = realloc(buf, ncap);
|
||||
if (!nbuf) { free(buf); close(fd); pattern_error_fill(err, NULL, "out of memory reading file", (int64_t)len); return NULL; }
|
||||
buf = nbuf; cap = ncap;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
close(fd);
|
||||
Input *in = calloc(1, sizeof(Input));
|
||||
if (!in) { free(buf); pattern_error_fill(err, NULL, "out of memory reading file", 0); return NULL; }
|
||||
in->buf = buf; in->len = len; in->owns_buf = 1;
|
||||
return in;
|
||||
}
|
||||
|
||||
/* Prefers mmap() over reading the file into a malloc'd copy: the
|
||||
* mapped pages are backed directly by the file and stay clean (never
|
||||
* written), so the kernel can reclaim them under memory pressure and
|
||||
* page them back in from disk later, instead of them being pinned for
|
||||
* the whole match attempt the way a malloc'd copy would be; it also
|
||||
* skips one whole redundant copy of the file's bytes (README.md
|
||||
* "Implementation status" records the memory-per-input-byte
|
||||
* multiplier this and the other fixes around it were measured
|
||||
* against). Only applies to regular, non-empty, seekable files;
|
||||
* anything else (a pipe, a FIFO, an empty file) falls back to
|
||||
* read_fd_incrementally, exactly as before mmap support existed. */
|
||||
Input *Input_from_file(const char *path, PatternError *err) {
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0) { pattern_error_fill(err, NULL, strerror(errno), 0); return NULL; }
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode) && st.st_size > 0) {
|
||||
void *addr = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
if (addr != MAP_FAILED) {
|
||||
close(fd);
|
||||
Input *in = calloc(1, sizeof(Input));
|
||||
if (!in) { munmap(addr, (size_t)st.st_size); pattern_error_fill(err, NULL, "out of memory reading file", 0); return NULL; }
|
||||
in->buf = addr; in->len = (size_t)st.st_size; in->owns_buf = 0; in->is_mmap = 1;
|
||||
return in;
|
||||
}
|
||||
/* mmap failed (unusual: an overcommit-restricted system, a
|
||||
* filesystem that does not support it, and so on); the fd is
|
||||
* still open and positioned at 0, so fall back to reading it
|
||||
* the ordinary way instead of failing outright. */
|
||||
return read_fd_incrementally(fd, err);
|
||||
}
|
||||
/* Empty regular file, or not a regular file at all: size is 0 or
|
||||
* unknowable upfront, and mmap does not apply either way. */
|
||||
return read_fd_incrementally(fd, err);
|
||||
}
|
||||
void Input_free(Input *in) {
|
||||
if (!in) return;
|
||||
if (in->owns_buf) free(in->buf);
|
||||
if (in->is_mmap) munmap(in->buf, in->len);
|
||||
else if (in->owns_buf) free(in->buf);
|
||||
free(in);
|
||||
}
|
||||
|
||||
@@ -1324,7 +1394,11 @@ void Input_free(Input *in) {
|
||||
* ================================================================ */
|
||||
|
||||
typedef struct {
|
||||
uint32_t *text;
|
||||
uint32_t *text; /* UTF8 mode only: owned, decoded code points */
|
||||
const uint8_t *text8; /* BINARY/ASCII mode only: borrowed, points directly
|
||||
* into the Input's own buffer, no widening copy;
|
||||
* see text_at() and README.md "Implementation
|
||||
* status" for why this matters at real scale. */
|
||||
int64_t len;
|
||||
int64_t *cp_to_byte; /* len+1 entries, NULL for non-UTF8 */
|
||||
} MatBuf;
|
||||
@@ -1343,18 +1417,41 @@ static int build_matbuf(Pattern *pat, Input *in, MatBuf *mb, PatternError *err)
|
||||
da_free(&cps); da_free(&offs);
|
||||
return 0;
|
||||
}
|
||||
uint32_t *cpp = da_push(&cps); *cpp = cp;
|
||||
int64_t *op = da_push(&offs); *op = (int64_t)i;
|
||||
uint32_t *cpp = da_push(&cps);
|
||||
int64_t *op = da_push(&offs);
|
||||
if (!cpp || !op) {
|
||||
pattern_error_fill(err, NULL, "out of memory decoding UTF-8 subject", (int64_t)i);
|
||||
da_free(&cps); da_free(&offs);
|
||||
return 0;
|
||||
}
|
||||
*cpp = cp; *op = (int64_t)i;
|
||||
i += (size_t)n;
|
||||
}
|
||||
int64_t *sentinel = da_push(&offs); *sentinel = (int64_t)in->len;
|
||||
int64_t *sentinel = da_push(&offs);
|
||||
if (!sentinel) {
|
||||
pattern_error_fill(err, NULL, "out of memory decoding UTF-8 subject", (int64_t)in->len);
|
||||
da_free(&cps); da_free(&offs);
|
||||
return 0;
|
||||
}
|
||||
*sentinel = (int64_t)in->len;
|
||||
mb->text = (uint32_t *)cps.data;
|
||||
mb->text8 = NULL;
|
||||
mb->cp_to_byte = (int64_t *)offs.data;
|
||||
mb->len = (int64_t)cps.len;
|
||||
} else {
|
||||
uint32_t *text = malloc((in->len ? in->len : 1) * sizeof(uint32_t));
|
||||
for (size_t i = 0; i < in->len; i++) text[i] = in->buf[i];
|
||||
mb->text = text; mb->len = (int64_t)in->len; mb->cp_to_byte = NULL;
|
||||
/* No widening copy: a byte never exceeds 255, so BINARY/ASCII
|
||||
* mode reads the Input's own buffer directly. This is the
|
||||
* single biggest lever found profiling a real search with
|
||||
* Massif (README.md "Implementation status"): the widened
|
||||
* uint32_t copy this replaces cost as much memory as the
|
||||
* input itself, four times over, for every mode that never
|
||||
* actually needed code points wider than a byte. mb->text8 is
|
||||
* a borrowed pointer (into Input, which outlives the MatBuf
|
||||
* that borrows it), never freed here. */
|
||||
mb->text = NULL;
|
||||
mb->text8 = in->buf;
|
||||
mb->len = (int64_t)in->len;
|
||||
mb->cp_to_byte = NULL;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -1417,13 +1514,22 @@ static uint8_t *alloc_memo(Prog *pr, int64_t textlen) {
|
||||
* recursive calls at all. int32_t bounds a single repeat's run length
|
||||
* to ~2 billion, far past any input this build's fully-materializing
|
||||
* Input can hold in memory in the first place. */
|
||||
static int32_t *compute_maxrun(Prog *pr, Inst *in, const uint32_t *text, int64_t len) {
|
||||
/* Shared by compute_maxrun/compute_next_prevmatch, exactly like
|
||||
* text_at() above them for run() itself: text32 is non-NULL only in
|
||||
* UTF8 mode, in which case it holds real decoded code points; in
|
||||
* BINARY/ASCII mode text32 is NULL and text8 (the Input's own buffer,
|
||||
* no widening copy) is used instead. */
|
||||
static inline uint32_t buf_at(const uint32_t *text32, const uint8_t *text8, int64_t i) {
|
||||
return text32 ? text32[i] : text8[i];
|
||||
}
|
||||
|
||||
static int32_t *compute_maxrun(Prog *pr, Inst *in, const uint32_t *text32, const uint8_t *text8, int64_t len) {
|
||||
int32_t *mr = malloc((size_t)(len + 1) * sizeof(int32_t));
|
||||
if (!mr) return NULL;
|
||||
mr[len] = 0;
|
||||
Node *cls = (in->atomkind == 1) ? ((Node **)pr->classnodes.data)[in->data] : NULL;
|
||||
for (int64_t i = len - 1; i >= 0; i--) {
|
||||
uint32_t ch = text[i];
|
||||
uint32_t ch = buf_at(text32, text8, i);
|
||||
int m;
|
||||
if (in->atomkind == 0) m = char_eq(ch, in->data, pr->mode, pr->flags);
|
||||
else if (in->atomkind == 2) m = !(ch == '\n' && !(pr->flags & DOTALL));
|
||||
@@ -1439,12 +1545,12 @@ static int32_t *compute_maxrun(Prog *pr, Inst *in, const uint32_t *text, int64_t
|
||||
* length) memory per instruction for the multi-position operations
|
||||
* (search/finditer/split) that would otherwise redo the scan at every
|
||||
* position, so match/fullmatch (a single attempt) do not allocate it. */
|
||||
static int32_t **alloc_repeat_maxrun(Prog *pr, const uint32_t *text, int64_t len) {
|
||||
static int32_t **alloc_repeat_maxrun(Prog *pr, const uint32_t *text32, const uint8_t *text8, int64_t len) {
|
||||
int32_t **arr = calloc((size_t)pr->n, sizeof(int32_t *));
|
||||
if (!arr) return NULL;
|
||||
for (int i = 0; i < pr->n; i++)
|
||||
if (pr->insts[i].op == OP_REPEAT1)
|
||||
arr[i] = compute_maxrun(pr, &pr->insts[i], text, len);
|
||||
arr[i] = compute_maxrun(pr, &pr->insts[i], text32, text8, len);
|
||||
return arr;
|
||||
}
|
||||
static void free_repeat_maxrun(Prog *pr, int32_t **arr) {
|
||||
@@ -1471,18 +1577,18 @@ static int inst_atom_match(Prog *pr, Inst *in, uint32_t ch) {
|
||||
* (see the comment at its one call site). Built in one forward
|
||||
* O(textlen) pass instead of walked freshly, backward, from every
|
||||
* position a caller asks about it. */
|
||||
static int32_t *compute_next_prevmatch(Prog *pr, Inst *next, const uint32_t *text, int64_t len) {
|
||||
static int32_t *compute_next_prevmatch(Prog *pr, Inst *next, const uint32_t *text32, const uint8_t *text8, int64_t len) {
|
||||
if (len <= 0) return NULL;
|
||||
int32_t *pm = malloc((size_t)len * sizeof(int32_t));
|
||||
if (!pm) return NULL;
|
||||
int32_t last = -1;
|
||||
for (int64_t i = 0; i < len; i++) {
|
||||
if (inst_atom_match(pr, next, text[i])) last = (int32_t)i;
|
||||
if (inst_atom_match(pr, next, buf_at(text32, text8, i))) last = (int32_t)i;
|
||||
pm[i] = last;
|
||||
}
|
||||
return pm;
|
||||
}
|
||||
static int32_t **alloc_repeat_nextpm(Prog *pr, const uint32_t *text, int64_t len) {
|
||||
static int32_t **alloc_repeat_nextpm(Prog *pr, const uint32_t *text32, const uint8_t *text8, int64_t len) {
|
||||
int32_t **arr = calloc((size_t)pr->n, sizeof(int32_t *));
|
||||
if (!arr) return NULL;
|
||||
for (int i = 0; i < pr->n; i++) {
|
||||
@@ -1490,7 +1596,7 @@ static int32_t **alloc_repeat_nextpm(Prog *pr, const uint32_t *text, int64_t len
|
||||
if (i + 1 >= pr->n) continue;
|
||||
int nextop = pr->insts[i + 1].op;
|
||||
if (nextop == OP_CHAR || nextop == OP_ANY || nextop == OP_CLASS)
|
||||
arr[i] = compute_next_prevmatch(pr, &pr->insts[i + 1], text, len);
|
||||
arr[i] = compute_next_prevmatch(pr, &pr->insts[i + 1], text32, text8, len);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
@@ -1513,14 +1619,14 @@ static int do_one(Pattern *self, Input *string, int64_t pos, int64_t endpos, int
|
||||
* only search pays for the run-length precompute (see
|
||||
* compute_maxrun); match/fullmatch's single attempt gets no
|
||||
* benefit from it and skips the O(text length) memory. */
|
||||
int32_t **maxrun = anchored ? NULL : alloc_repeat_maxrun(&impl->prog, mb.text, ep);
|
||||
int32_t **nextpm = anchored ? NULL : alloc_repeat_nextpm(&impl->prog, mb.text, ep);
|
||||
int32_t **maxrun = anchored ? NULL : alloc_repeat_maxrun(&impl->prog, mb.text, mb.text8, ep);
|
||||
int32_t **nextpm = anchored ? NULL : alloc_repeat_nextpm(&impl->prog, mb.text, mb.text8, ep);
|
||||
int found = 0;
|
||||
int64_t last_start = anchored ? p0 : ep;
|
||||
for (int64_t start = p0; start <= last_start && !found; start++) {
|
||||
reset_caps(caps, ng);
|
||||
caps[0] = start;
|
||||
MCtx c; c.pr = &impl->prog; c.text = mb.text; c.len = ep; c.caps = caps;
|
||||
MCtx c; c.pr = &impl->prog; c.text = mb.text; c.text8 = mb.text8; c.len = ep; c.caps = caps;
|
||||
c.depth_exceeded = 0; c.require_end = fullmatch ? ep : -1; c.sub_end = -1; c.memo = memo;
|
||||
c.repeat_maxrun = maxrun; c.repeat_nextpm = nextpm; c.forbid_empty = 0;
|
||||
found = run_memo(&c, 0, start, -1, -1, 0);
|
||||
@@ -1558,8 +1664,8 @@ int Pattern_finditer(Pattern *self, Input *string, int64_t pos, int64_t endpos,
|
||||
* false later in the same scan, so nothing here ever needs to
|
||||
* invalidate or reset it between matches. */
|
||||
uint8_t *memo = alloc_memo(&impl->prog, ep);
|
||||
int32_t **maxrun = alloc_repeat_maxrun(&impl->prog, mb.text, ep); /* see compute_maxrun */
|
||||
int32_t **nextpm = alloc_repeat_nextpm(&impl->prog, mb.text, ep); /* see compute_next_prevmatch */
|
||||
int32_t **maxrun = alloc_repeat_maxrun(&impl->prog, mb.text, mb.text8, ep); /* see compute_maxrun */
|
||||
int32_t **nextpm = alloc_repeat_nextpm(&impl->prog, mb.text, mb.text8, ep); /* see compute_next_prevmatch */
|
||||
int count = 0;
|
||||
while (start <= ep) {
|
||||
int64_t *caps; alloc_caps(&caps, ng);
|
||||
@@ -1568,7 +1674,7 @@ int Pattern_finditer(Pattern *self, Input *string, int64_t pos, int64_t endpos,
|
||||
for (s = start; s <= ep && !found; s++) {
|
||||
reset_caps(caps, ng);
|
||||
caps[0] = s;
|
||||
MCtx c; c.pr = &impl->prog; c.text = mb.text; c.len = ep; c.caps = caps;
|
||||
MCtx c; c.pr = &impl->prog; c.text = mb.text; c.text8 = mb.text8; c.len = ep; c.caps = caps;
|
||||
c.depth_exceeded = 0; c.require_end = -1; c.sub_end = -1; c.memo = memo;
|
||||
c.repeat_maxrun = maxrun; c.repeat_nextpm = nextpm; c.forbid_empty = 0;
|
||||
found = run_memo(&c, 0, s, -1, -1, 0);
|
||||
@@ -1595,7 +1701,7 @@ int Pattern_finditer(Pattern *self, Input *string, int64_t pos, int64_t endpos,
|
||||
int64_t *caps2; alloc_caps(&caps2, ng);
|
||||
reset_caps(caps2, ng);
|
||||
caps2[0] = mstart;
|
||||
MCtx c2; c2.pr = &impl->prog; c2.text = mb.text; c2.len = ep; c2.caps = caps2;
|
||||
MCtx c2; c2.pr = &impl->prog; c2.text = mb.text; c2.text8 = mb.text8; c2.len = ep; c2.caps = caps2;
|
||||
c2.depth_exceeded = 0; c2.require_end = -1; c2.sub_end = -1; c2.memo = memo;
|
||||
c2.repeat_maxrun = maxrun; c2.repeat_nextpm = nextpm; c2.forbid_empty = 1;
|
||||
int found2 = run_memo(&c2, 0, mstart, -1, -1, 0);
|
||||
@@ -1646,8 +1752,8 @@ int Pattern_split(Pattern *self, Input *string, int maxsplit, MatchIterCb cb, vo
|
||||
PatternImpl *impl = (PatternImpl *)self->program;
|
||||
int ng = impl->prog.ngroups;
|
||||
uint8_t *memo = alloc_memo(&impl->prog, ep); /* one table for the whole scan, see MCtx.memo */
|
||||
int32_t **maxrun = alloc_repeat_maxrun(&impl->prog, mb.text, ep); /* see compute_maxrun */
|
||||
int32_t **nextpm = alloc_repeat_nextpm(&impl->prog, mb.text, ep); /* see compute_next_prevmatch */
|
||||
int32_t **maxrun = alloc_repeat_maxrun(&impl->prog, mb.text, mb.text8, ep); /* see compute_maxrun */
|
||||
int32_t **nextpm = alloc_repeat_nextpm(&impl->prog, mb.text, mb.text8, ep); /* see compute_next_prevmatch */
|
||||
int n = 0;
|
||||
while (start <= ep) {
|
||||
if (maxsplit > 0 && n >= maxsplit) break;
|
||||
@@ -1656,7 +1762,7 @@ int Pattern_split(Pattern *self, Input *string, int maxsplit, MatchIterCb cb, vo
|
||||
for (s = start; s <= ep && !found; s++) {
|
||||
reset_caps(caps, ng);
|
||||
caps[0] = s;
|
||||
MCtx c; c.pr = &impl->prog; c.text = mb.text; c.len = ep; c.caps = caps;
|
||||
MCtx c; c.pr = &impl->prog; c.text = mb.text; c.text8 = mb.text8; c.len = ep; c.caps = caps;
|
||||
c.depth_exceeded = 0; c.require_end = -1; c.sub_end = -1; c.memo = memo;
|
||||
c.repeat_maxrun = maxrun; c.repeat_nextpm = nextpm; c.forbid_empty = 0;
|
||||
found = run_memo(&c, 0, s, -1, -1, 0);
|
||||
@@ -1675,7 +1781,7 @@ int Pattern_split(Pattern *self, Input *string, int maxsplit, MatchIterCb cb, vo
|
||||
int64_t *caps2; alloc_caps(&caps2, ng);
|
||||
reset_caps(caps2, ng);
|
||||
caps2[0] = mstart;
|
||||
MCtx c2; c2.pr = &impl->prog; c2.text = mb.text; c2.len = ep; c2.caps = caps2;
|
||||
MCtx c2; c2.pr = &impl->prog; c2.text = mb.text; c2.text8 = mb.text8; c2.len = ep; c2.caps = caps2;
|
||||
c2.depth_exceeded = 0; c2.require_end = -1; c2.sub_end = -1; c2.memo = memo;
|
||||
c2.repeat_maxrun = maxrun; c2.repeat_nextpm = nextpm; c2.forbid_empty = 1;
|
||||
int found2 = run_memo(&c2, 0, mstart, -1, -1, 0);
|
||||
|
||||
Reference in New Issue
Block a user