This is an automated email from the git hooks/post-receive script.

git pushed a commit to branch perf/final
in repository terminology.

View the commit online.

commit df2b96d2571cf6642c4b63b2127d55f527f2c852
Author: Cedric BAIL <[email protected]>
AuthorDate: Mon Aug 3 13:27:16 2026 -0600

    build: run the test suite from meson, add a pty intake benchmark
    
    tests/ was invisible to the build system and only ever ran from CircleCI.
    Register it, so `meson test` runs the escape-code suite, the chunked replays
    and the unit tests.
    
    tybench feeds a corpus through exactly what the shipping binary runs when
    bytes arrive from the pty -- utf8_to_codepoints() then termpty_handle_buf()
    -- and reports MB/s and ns/byte. --chunk sizes the simulated read, so the
    effect of read sizing is measurable rather than argued about.
    
    It is built with BINARY_TYFUZZ for the headless setup, plus BINARY_TYBENCH so
    that private.h leaves eina logging compiled in. That distinction matters:
    tytest and tyfuzz define EINA_LOG_LEVEL_MAXIMUM and so cannot observe what
    logging costs, which is how an snprintf per character survived in the hot
    path unnoticed. A benchmark that cannot see a cost cannot be used to remove
    it.
    
    Corpora are generated rather than committed -- megabytes of derived data --
    by tests/bench/gen_corpus.py, which is deterministic so numbers taken on
    different days are comparable. Four workloads: plain ASCII, scroll-heavy,
    SGR-heavy and unicode, chosen so a change to one part of the intake path
    shows up in one column instead of being averaged away.
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
---
 meson.build               |   8 ++
 meson_options.txt         |   4 +
 src/bin/meson.build       |  30 ++++-
 src/bin/private.h         |   6 +-
 src/bin/tybench.c         | 285 ++++++++++++++++++++++++++++++++++++++++++++++
 tests/bench/gen_corpus.py | 158 +++++++++++++++++++++++++
 tests/meson.build         |  59 ++++++++++
 7 files changed, 548 insertions(+), 2 deletions(-)

diff --git a/meson.build b/meson.build
index 780e94c7..94da1bc5 100644
--- a/meson.build
+++ b/meson.build
@@ -141,6 +141,13 @@ else
   message('Tests are disabled')
 endif
 
+benchmarks = get_option('benchmarks')
+if benchmarks
+  message('Benchmarks are enabled')
+else
+  message('Benchmarks are disabled')
+endif
+
 message('edje_cc set to:' + edje_cc)
 
 sed = find_program('sed')
@@ -152,4 +159,5 @@ config_dir = include_directories('.')
 subdir('data')
 subdir('man')
 subdir('src/bin')
+subdir('tests')
 
diff --git a/meson_options.txt b/meson_options.txt
index be2b29da..601d215a 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -12,6 +12,10 @@ option('tests',
        type: 'boolean',
        value: false,
        description: 'Enable generating tytest, used to run tests. (default=false)')
+option('benchmarks',
+       type: 'boolean',
+       value: false,
+       description: 'Enable generating tybench, used to benchmark the pty intake path. (default=false)')
 option('nls',
        type: 'boolean',
        value: true,
diff --git a/src/bin/meson.build b/src/bin/meson.build
index f12f5d7a..0972c6d7 100644
--- a/src/bin/meson.build
+++ b/src/bin/meson.build
@@ -85,6 +85,23 @@ tytest_sources = ['termptyesc.c', 'termptyesc.h',
                   'unit_tests.h',
                   'tytest_common.c', 'tytest_common.h',
                   'tytest.c', 'tytest.h']
+tybench_sources = ['termptyesc.c', 'termptyesc.h',
+                  'backlog.c', 'backlog.h',
+                  'termptyops.c', 'termptyops.h',
+                  'termptydbl.c', 'termptydbl.h',
+                  'termptyext.c', 'termptyext.h',
+                  'termptygfx.c', 'termptygfx.h',
+                  'termpty.c', 'termpty.h',
+                  'termiointernals.c', 'termiointernals.h',
+                  'termiolink.c', 'termiolink.h',
+                  'config.c', 'config.h',
+                  'colors.c', 'colors.h',
+                  'sb.c', 'sb.h',
+                  'theme.h',
+                  'utils.c', 'utils.h',
+                  'utf8.c', 'utf8.h',
+                  'tytest_common.c', 'tytest_common.h',
+                  'tybench.c']
 
 executable('terminology',
            terminology_sources,
@@ -137,10 +154,21 @@ if fuzzing
              dependencies: terminology_dependencies)
 endif
 if tests
-  executable('tytest',
+  tytest = executable('tytest',
              tytest_sources,
              install: true,
              include_directories: config_dir,
              c_args: '-DBINARY_TYTEST=1',
              dependencies: terminology_dependencies)
 endif
+
+if benchmarks
+  # BINARY_TYFUZZ for the headless setup, BINARY_TYBENCH to keep eina logging
+  # compiled in so the numbers include what it costs.
+  tybench = executable('tybench',
+             tybench_sources,
+             install: false,
+             include_directories: config_dir,
+             c_args: ['-DBINARY_TYFUZZ=1', '-DBINARY_TYBENCH=1'],
+             dependencies: terminology_dependencies)
+endif
diff --git a/src/bin/private.h b/src/bin/private.h
index db3e5865..876542cf 100644
--- a/src/bin/private.h
+++ b/src/bin/private.h
@@ -24,8 +24,12 @@ extern int terminology_starting_up;
 //#define ENABLE_TEST_UI
 #endif
 
+/* Test binaries compile logging out. tybench must not: it measures what the
+ * shipping binary costs, and logging is part of that. */
 #if defined(BINARY_TYFUZZ) || defined(BINARY_TYTEST)
-#define EINA_LOG_LEVEL_MAXIMUM (-1)
+# if !defined(BINARY_TYBENCH)
+#  define EINA_LOG_LEVEL_MAXIMUM (-1)
+# endif
 #endif
 extern int _log_domain;
 
diff --git a/src/bin/tybench.c b/src/bin/tybench.c
new file mode 100644
index 00000000..e857e371
--- /dev/null
+++ b/src/bin/tybench.c
@@ -0,0 +1,285 @@
+/* Throughput benchmark for the pty intake path.
+ *
+ * Feeds a corpus through exactly the code the shipping binary runs when bytes
+ * arrive from the pty -- UTF-8 decode plus termpty_handle_buf() -- and reports
+ * how fast it goes. The read() syscall itself is deliberately left out: it is
+ * measured by changing the chunk size rather than by timing the kernel.
+ *
+ * Built without EINA_LOG_LEVEL_MAXIMUM (see private.h) so that logging calls
+ * cost here what they cost in production.
+ */
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <time.h>
+#include <sys/stat.h>
+
+#include "private.h"
+#include <Elementary.h>
+#include "config.h"
+#include "termpty.h"
+#include "tytest_common.h"
+
+int _log_domain = -1;
+
+#define DEFAULT_ITERATIONS 10
+#define DEFAULT_WARMUP      2
+#define DEFAULT_CHUNK    4096
+
+typedef struct tag_Corpus
+{
+   const char *name;
+   char       *data;
+   long        len;
+} Corpus;
+
+static double
+_now(void)
+{
+   struct timespec ts;
+
+   clock_gettime(CLOCK_MONOTONIC, &ts);
+   return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9;
+}
+
+static const char *
+_basename(const char *path)
+{
+   const char *slash = strrchr(path, '/');
+
+   return slash ? slash + 1 : path;
+}
+
+static Eina_Bool
+_corpus_load(Corpus *c, const char *path)
+{
+   struct stat st;
+   long got = 0;
+   int fd;
+
+   fd = open(path, O_RDONLY);
+   if (fd < 0)
+     {
+        fprintf(stderr, "tybench: cannot open %s\n", path);
+        return EINA_FALSE;
+     }
+   if (fstat(fd, &st) < 0 || st.st_size <= 0)
+     {
+        fprintf(stderr, "tybench: cannot size %s\n", path);
+        close(fd);
+        return EINA_FALSE;
+     }
+
+   c->len = (long)st.st_size;
+   c->data = ""
+   if (!c->data)
+     {
+        fprintf(stderr, "tybench: out of memory for %s\n", path);
+        close(fd);
+        return EINA_FALSE;
+     }
+
+   while (got < c->len)
+     {
+        ssize_t n = read(fd, c->data + got, c->len - got);
+
+        if (n <= 0) break;
+        got += n;
+     }
+   close(fd);
+
+   if (got != c->len)
+     {
+        fprintf(stderr, "tybench: short read on %s\n", path);
+        free(c->data);
+        c->data = ""
+        return EINA_FALSE;
+     }
+
+   c->name = _basename(path);
+   return EINA_TRUE;
+}
+
+/* One pass over the corpus, split into chunk-sized pieces so the benchmark can
+ * show what read() sizing is worth. */
+static void
+_feed_pass(const Corpus *c, int chunk)
+{
+   long off;
+
+   for (off = 0; off < c->len; off += chunk)
+     {
+        int n = (c->len - off < chunk) ? (int)(c->len - off) : chunk;
+
+        tytest_common_feed(c->data + off, n);
+     }
+}
+
+/* Put the terminal back to a known state between corpora, so one corpus cannot
+ * leave modes set that change how the next one parses. */
+static void
+_reset_terminal(void)
+{
+   static const char ris[] = "\033c";
+
+   tytest_common_feed(ris, sizeof(ris) - 1);
+}
+
+static void
+_usage(const char *argv0)
+{
+   fprintf(stderr,
+           "usage: %s [options] <corpus>...\n"
+           "\n"
+           "  -i, --iterations=N  timed passes over each corpus (default %d)\n"
+           "  -w, --warmup=N      untimed passes before timing (default %d)\n"
+           "  -c, --chunk=N       bytes per simulated read() (default %d)\n"
+           "  -t, --tsv           tab-separated output for scripting\n"
+           "  -h, --help          this message\n",
+           argv0, DEFAULT_ITERATIONS, DEFAULT_WARMUP, DEFAULT_CHUNK);
+}
+
+static int
+_int_opt(const char *arg, const char *longform, int *out)
+{
+   size_t n = strlen(longform);
+
+   if (strncmp(arg, longform, n) != 0) return 0;
+   if (arg[n] != '=') return 0;
+   *out = atoi(arg + n + 1);
+   return 1;
+}
+
+int
+main(int argc, char **argv)
+{
+   int iterations = DEFAULT_ITERATIONS;
+   int warmup = DEFAULT_WARMUP;
+   int chunk = DEFAULT_CHUNK;
+   Eina_Bool tsv = EINA_FALSE;
+   int i, ncorpus = 0;
+   Corpus *corpus;
+
+   corpus = calloc(argc, sizeof(Corpus));
+   if (!corpus) return 1;
+
+   for (i = 1; i < argc; i++)
+     {
+        const char *a = argv[i];
+
+        if (!strcmp(a, "-h") || !strcmp(a, "--help"))
+          {
+             _usage(argv[0]);
+             free(corpus);
+             return 0;
+          }
+        else if (!strcmp(a, "-t") || !strcmp(a, "--tsv"))
+          tsv = EINA_TRUE;
+        else if (!strcmp(a, "-i") && i + 1 < argc)
+          iterations = atoi(argv[++i]);
+        else if (!strcmp(a, "-w") && i + 1 < argc)
+          warmup = atoi(argv[++i]);
+        else if (!strcmp(a, "-c") && i + 1 < argc)
+          chunk = atoi(argv[++i]);
+        else if (_int_opt(a, "--iterations", &iterations))
+          continue;
+        else if (_int_opt(a, "--warmup", &warmup))
+          continue;
+        else if (_int_opt(a, "--chunk", &chunk))
+          continue;
+        else if (a[0] == '-')
+          {
+             fprintf(stderr, "tybench: unknown option %s\n", a);
+             _usage(argv[0]);
+             free(corpus);
+             return 1;
+          }
+        else
+          {
+             if (!_corpus_load(&corpus[ncorpus], a))
+               {
+                  free(corpus);
+                  return 1;
+               }
+             ncorpus++;
+          }
+     }
+
+   if (ncorpus == 0)
+     {
+        _usage(argv[0]);
+        free(corpus);
+        return 1;
+     }
+   if (iterations < 1) iterations = 1;
+   if (warmup < 0) warmup = 0;
+   if (chunk < 1) chunk = 1;
+
+   eina_init();
+   _log_domain = eina_log_domain_register("tybench", NULL);
+   /* The parser logs to its own domain. Registering it matters here in a way it
+    * does not for tytest/tyfuzz: those compile logging out entirely, whereas
+    * this binary keeps it, and an unregistered domain sends every DBG down
+    * eina's "unknown domain" complaint path instead of the cheap level check
+    * that production takes. */
+   termpty_init();
+   tytest_common_init();
+
+   if (!tsv)
+     {
+        printf("chunk=%d iterations=%d warmup=%d\n\n", chunk, iterations, warmup);
+        printf("%-16s %10s %8s %10s %10s\n",
+               "corpus", "bytes", "MB/s", "ns/byte", "best MB/s");
+        printf("%-16s %10s %8s %10s %10s\n",
+               "----------------", "----------", "--------",
+               "----------", "----------");
+     }
+
+   for (i = 0; i < ncorpus; i++)
+     {
+        double total, best_pass = 0.0, mbs, best_mbs;
+        int pass;
+
+        _reset_terminal();
+        for (pass = 0; pass < warmup; pass++)
+          _feed_pass(&corpus[i], chunk);
+
+        total = 0.0;
+        for (pass = 0; pass < iterations; pass++)
+          {
+             double t0, dt;
+
+             t0 = _now();
+             _feed_pass(&corpus[i], chunk);
+             dt = _now() - t0;
+
+             total += dt;
+             if (pass == 0 || dt < best_pass) best_pass = dt;
+          }
+
+        mbs = ((double)corpus[i].len * iterations) / total / 1e6;
+        best_mbs = (double)corpus[i].len / best_pass / 1e6;
+
+        if (tsv)
+          printf("%s\t%ld\t%d\t%d\t%.6f\t%.3f\t%.3f\n",
+                 corpus[i].name, corpus[i].len, chunk, iterations,
+                 total, mbs, best_mbs);
+        else
+          printf("%-16s %10ld %8.2f %10.3f %10.2f\n",
+                 corpus[i].name, corpus[i].len, mbs,
+                 total * 1e9 / ((double)corpus[i].len * iterations), best_mbs);
+
+        fflush(stdout);
+     }
+
+   for (i = 0; i < ncorpus; i++)
+     free(corpus[i].data);
+   free(corpus);
+
+   tytest_common_shutdown();
+   eina_shutdown();
+
+   return 0;
+}
diff --git a/tests/bench/gen_corpus.py b/tests/bench/gen_corpus.py
new file mode 100644
index 00000000..929b2663
--- /dev/null
+++ b/tests/bench/gen_corpus.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""Generate deterministic benchmark corpora for tybench.
+
+Four workloads, chosen so that a change to one part of the intake path shows up
+in one column rather than being averaged away:
+
+  plain-ascii  long lines of printable ASCII, almost no escapes. The pure
+               text-append path -- what an ASCII fast path targets.
+  scroll       short lines, so the cost is dominated by line feeds, wrapping and
+               backlog pushes rather than by cell writes.
+  sgr          colour-saturated output. Exercises the CSI parser and its
+               parameter scanning instead of the text path.
+  unicode      CJK, emoji, combining marks and accented Latin. Exercises
+               multibyte decode, double-width handling, and the paths a
+               byte-space fast path has to bail out of correctly.
+
+Output is byte-identical across runs (fixed seed, no clock, no locale) so that
+numbers from different days are comparable.
+"""
+
+import argparse
+import os
+import random
+
+TARGET_DEFAULT = 4 * 1024 * 1024
+
+WORDS = (
+    "terminal escape sequence parser buffer cursor render glyph column row "
+    "codepoint attribute palette scrollback viewport selection backlog cell "
+    "unicode decode dispatch throughput latency kernel syscall pipeline vector"
+).split()
+
+
+def _rng(tag):
+    # Per-corpus seed: adding a corpus cannot change the bytes of another.
+    return random.Random("terminology-bench:" + tag)
+
+
+def gen_plain_ascii(target):
+    """Long lines of printable ASCII. Maximises consecutive printable runs."""
+    rng = _rng("plain-ascii")
+    out = bytearray()
+    while len(out) < target:
+        line = []
+        width = 0
+        # Aim well past the 80-column screen so wrapping is exercised too.
+        while width < 100:
+            w = rng.choice(WORDS)
+            line.append(w)
+            width += len(w) + 1
+        out += (" ".join(line) + "\n").encode("ascii")
+    return bytes(out[:target])
+
+
+def gen_scroll(target):
+    """Short lines: one newline every few bytes, so scrolling dominates."""
+    rng = _rng("scroll")
+    out = bytearray()
+    n = 0
+    while len(out) < target:
+        out += ("%6d %s\n" % (n, rng.choice(WORDS))).encode("ascii")
+        n += 1
+    return bytes(out[:target])
+
+
+def gen_sgr(target):
+    """Colour-heavy output: an SGR sequence for nearly every short text run."""
+    rng = _rng("sgr")
+    out = bytearray()
+    while len(out) < target:
+        style = rng.choice(
+            [
+                "\033[%dm" % rng.randint(30, 37),
+                "\033[1;%dm" % rng.randint(30, 37),
+                "\033[38;5;%dm" % rng.randint(0, 255),
+                "\033[48;5;%dm" % rng.randint(0, 255),
+                "\033[38;2;%d;%d;%dm"
+                % (rng.randint(0, 255), rng.randint(0, 255), rng.randint(0, 255)),
+                "\033[0m",
+                "\033[1m",
+                "\033[4m",
+            ]
+        )
+        out += (style + rng.choice(WORDS)).encode("ascii")
+        if rng.random() < 0.15:
+            out += b"\033[0m\n"
+    return bytes(out[:target])
+
+
+def gen_unicode(target):
+    """Mixed multibyte: 2-, 3- and 4-byte sequences plus combining marks."""
+    rng = _rng("unicode")
+    pools = [
+        "éèêüñåøæ",   # 2-byte Latin-1
+        "你好世界漢字日本",   # 3-byte CJK, wide
+        "αβγδЖДЯш",   # 2-byte Greek/Cyrillic
+        "\U0001f600\U0001f680\U0001f4a1\U0001f30d",           # 4-byte emoji
+    ]
+    out = bytearray()
+    while len(out) < target:
+        line = []
+        for _ in range(rng.randint(8, 20)):
+            pool = rng.choice(pools)
+            chunk = "".join(rng.choice(pool) for _ in range(rng.randint(1, 6)))
+            # Sprinkle combining acute accents onto some Latin runs.
+            if pool is pools[0] and rng.random() < 0.3:
+                chunk += "́"
+            line.append(chunk)
+        # Interleave ASCII so the corpus exercises transitions in and out of the
+        # multibyte path rather than staying in one mode.
+        line.append(rng.choice(WORDS))
+        out += (" ".join(line) + "\n").encode("utf-8")
+    # Never truncate mid-sequence: that would make the corpus itself invalid.
+    data = ""
+    cut = target
+    while cut > 0 and (data[cut] & 0xC0) == 0x80:
+        cut -= 1
+    return data[:cut]
+
+
+GENERATORS = {
+    "plain-ascii": gen_plain_ascii,
+    "scroll": gen_scroll,
+    "sgr": gen_sgr,
+    "unicode": gen_unicode,
+}
+
+
+def main():
+    ap = argparse.ArgumentParser(description=__doc__,
+                                 formatter_class=argparse.RawDescriptionHelpFormatter)
+    ap.add_argument("outdir", help="directory to write corpora into")
+    ap.add_argument("-s", "--size", type=int, default=TARGET_DEFAULT,
+                    help="approximate bytes per corpus (default %d)" % TARGET_DEFAULT)
+    ap.add_argument("-o", "--only", action="" choices=sorted(GENERATORS),
+                    help="generate only this corpus (repeatable)")
+    args = ap.parse_args()
+
+    os.makedirs(args.outdir, exist_ok=True)
+    names = args.only if args.only else sorted(GENERATORS)
+
+    for name in names:
+        data = ""
+        path = os.path.join(args.outdir, name)
+        # Skip the rewrite if content is already correct, so timestamps stay put
+        # and build systems do not re-run downstream steps for nothing.
+        if os.path.exists(path):
+            with open(path, "rb") as f:
+                if f.read() == data:
+                    print("%-14s %9d bytes (unchanged)" % (name, len(data)))
+                    continue
+        with open(path, "wb") as f:
+            f.write(data)
+        print("%-14s %9d bytes" % (name, len(data)))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/tests/meson.build b/tests/meson.build
new file mode 100644
index 00000000..25ec8267
--- /dev/null
+++ b/tests/meson.build
@@ -0,0 +1,59 @@
+if tests
+  run_tests = find_program('run_tests.sh')
+
+  # Each script's output is piped through tytest and the resulting state
+  # checksum compared against tests.results.
+  test('escape-codes',
+       run_tests,
+       args: ['-v',
+              '-t', tytest.full_path(),
+              '-r', meson.current_source_dir() / 'tests.results',
+              '-d', meson.current_source_dir()],
+       depends: tytest,
+       workdir: meson.current_source_dir(),
+       timeout: 300)
+
+  # Replayed with tiny reads, so every escape sequence and every multibyte
+  # character straddles a read boundary. Results must match the run above.
+  foreach chunk : ['1', '3', '7']
+    test('escape-codes-chunk' + chunk,
+         run_tests,
+         args: ['-v',
+                '-t', tytest.full_path(),
+                '-r', meson.current_source_dir() / 'tests.results',
+                '-d', meson.current_source_dir(),
+                '--chunk=' + chunk],
+         depends: tytest,
+         workdir: meson.current_source_dir(),
+         timeout: 600)
+  endforeach
+
+  # The in-process C unit tests compiled into tytest itself.
+  test('unit', tytest, args: ['all'], timeout: 120)
+endif
+
+if benchmarks
+  python3 = find_program('python3')
+  bench_corpus_dir = meson.current_build_dir()
+
+  # Generated rather than committed: megabytes of derived data, and the
+  # generator is deterministic so the bytes are reproducible. It writes to
+  # @OUTDIR@ so that what it produces is what 'output' declares.
+  bench_corpus = custom_target('bench-corpus',
+      output: ['plain-ascii', 'scroll', 'sgr', 'unicode'],
+      command: [python3,
+                meson.current_source_dir() / 'bench' / 'gen_corpus.py',
+                '@OUTDIR@'],
+      build_by_default: false)
+
+  # Run with `meson test --benchmark`.
+  benchmark('pty-intake',
+            tybench,
+            args: ['-i', '5',
+                   bench_corpus_dir / 'plain-ascii',
+                   bench_corpus_dir / 'scroll',
+                   bench_corpus_dir / 'sgr',
+                   bench_corpus_dir / 'unicode'],
+            depends: bench_corpus,
+            timeout: 600)
+endif

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.

Reply via email to