This is an automated email from the git hooks/post-receive script.
git pushed a commit to reference refs/pull/51/head
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.