From e1ed46daedf7f4668db9e9e9362d2df0e59d94c3 Mon Sep 17 00:00:00 2001 From: Torstein Tauno Svendsen Date: Mon, 7 Sep 2026 19:33:27 +0200 Subject: [PATCH 1/2] A window is the default, in the shell and out of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `graph` could only write a file — `png 'f'`/`svg 'f'` — so seeing a plot meant leaving the shell for timbergraph, whose `--window` timbersh never exposed. Both now default to a WINDOW, and both take `window`/`ascii` (`--window`/`--ascii`) to say which outright. ASCII IS THE REASON. It cannot carry the detail: five series an order of magnitude apart collapse into two rows of characters and one of them disappears from the plot entirely, legend and all. A picture is the point of a graph. ONE RULE, in `choose_terminal`, called by both. Two copies would drift and the drift would be silent — the same words meaning different pictures depending which of the two you typed them in. A window wants three things and none implies another: a display, a gnuplot built with an interactive terminal (`gnuplot-nox` has none), and a TTY to be watching it. The tty binds the DEFAULT only, and it is not fussiness: `timbersh -q < script > out.txt` at a desk would otherwise open a window and write an empty file, turning a working non-interactive run into nothing. Naming `window` overrides it, a window not being stdout. A NAMED window that cannot be drawn is refused rather than downgraded, because the point of saying it is to get one. The probe reads gnuplot's TERMINAL LIST rather than its exit code: `set terminal nosuchterm` exits 0 and only warns, so a probe trusting the status would call every terminal available. One gnuplot at ~10 ms, cached, this being a fact about the machine. Two destinations is refused rather than resolved in silence — `window png 'f'` wrote the file and no window, `window ascii` was ASCII, `png` beside `svg` quietly preferred the png. In timbersh it is decided BEFORE the read, so such a statement costs no fleet query; and `Bad` is translated, since only ValueError reaches the loop's handler and a refusal left untranslated arrives as a traceback out of the shell. The tests can say what the machine can do — a `fake-gnuplot` reporting a chosen terminal list, and a per-session env override — because a suite inheriting the developer's DISPLAY would assert one thing at a desk and the opposite in CI. timbersh's default-window test runs on a PTY, the default being gated on one; each destination is asserted by what the fake was ASKED for rather than by the absence of the other, which would also pass if nothing ran. Co-Authored-By: Claude Opus 5 (1M context) --- packaging/timbergraph.1 | 34 ++++++- packaging/timbersh.1 | 30 ++++++ tests/timbergraph/test-timbergraph | 56 +++++++++++ tests/timbersh/fake-gnuplot | 19 ++++ tests/timbersh/test-timbersh | 150 ++++++++++++++++++++++++++++- tools/timbergraph.py | 103 +++++++++++++++++++- tools/timbersh | 53 ++++++++-- 7 files changed, 424 insertions(+), 21 deletions(-) create mode 100755 tests/timbersh/fake-gnuplot diff --git a/packaging/timbergraph.1 b/packaging/timbergraph.1 index 64382f3..996a589 100644 --- a/packaging/timbergraph.1 +++ b/packaging/timbergraph.1 @@ -166,10 +166,38 @@ where the name resolves to a different document or to none. .B \-\-list Say what the input holds and draw nothing. .TP -.BI \-\-png " FILE" ", " \-\-svg " FILE" ", " \-\-window -Draw somewhere other than the terminal. +.BI \-\-png " FILE" ", " \-\-svg " FILE" ", " \-\-window ", " \-\-ascii +WHERE to draw, and by DEFAULT that is a WINDOW \(em characters cannot +carry the detail, five series an order of magnitude apart collapsing into +two rows of them with one disappearing altogether. It needs three things +and none implies another: a display +.RB ( $DISPLAY +or +.BR $WAYLAND_DISPLAY ), +a +.BR gnuplot (1) +built with an interactive terminal +.RB ( gnuplot\-nox +has none), and a TTY to be watching it \(em so a redirected or scripted +run keeps its answer as text rather than losing it to a window nobody +sees. Missing any of them, the default is ASCII. +.IP +.B \-\-window +and +.B \-\-ascii +say which outright, and .B \-\-window -needs a display and gnuplot's qt or x11 terminal. +overrides the TTY test, a window not being stdout. A +.B \-\-window +that cannot be drawn is REFUSED rather than downgraded, because the point +of naming it is to get one. +.BI \-\-png " FILE" +and +.BI \-\-svg " FILE" +write a file. One of the four at a time: two name different places to +draw. ⚠ Identical in +.BR timbersh (1), +which calls this same rule rather than keeping its own. .TP .BI \-\-width " N" ", " \-\-height " N" The plot's size \(em CHARACTERS on the terminal, where it defaults to the diff --git a/packaging/timbersh.1 b/packaging/timbersh.1 index d93b983..62698cc 100644 --- a/packaging/timbersh.1 +++ b/packaging/timbersh.1 @@ -455,6 +455,36 @@ and .BR timbergraph (1) is the same drawing outside the shell. .PP +WHERE IT DRAWS: a WINDOW where one can be drawn, and characters on the +terminal where it cannot \(em so the same statement gives a picture at a +desk and still answers over ssh. A window wants two things, and neither +implies the other: a display +.RB ( $DISPLAY +or +.BR $WAYLAND_DISPLAY ) +and a +.BR gnuplot (1) +built with an interactive terminal, which +.B gnuplot\-nox +is not \(em and, for the DEFAULT only, a terminal to be watching it: a +redirected run keeps its answer as text rather than losing it to a +window nobody asked for. Naming +.B window +overrides that, a window not being stdout. +.B window +and +.B ascii +say which outright, and a +.B window +that cannot be drawn is REFUSED rather than quietly downgraded \(em the +point of naming it is to get one. +.BI "png " \(fmFILE\(fm +and +.BI "svg " \(fmFILE\(fm +write a file instead, which timbersh does not open for you. One of the +four at a time: two name different places to draw, and a statement whose +destination is picked for it means something other than what it says. +.PP .B view opens a store as a tape rather than as an answer, IN PROCESS: the loop it exists for is search, jump, search again, and a separate program would diff --git a/tests/timbergraph/test-timbergraph b/tests/timbergraph/test-timbergraph index ae0111e..f74b1ca 100755 --- a/tests/timbergraph/test-timbergraph +++ b/tests/timbergraph/test-timbergraph @@ -48,6 +48,62 @@ def raises(name, fn, contains): FAILED.append(f"{name}: no refusal, and {contains!r} was expected") +def test_where_it_draws_is_one_rule_for_both_tools(): + """`choose_terminal` is the whole of it, and timbersh calls this same + function — two copies would drift and the same words would then mean + different pictures depending which you typed them in. + + The default is a window because ASCII cannot carry the detail. It + wants a display, an interactive gnuplot terminal AND a tty, and the + tty binds the default only: a redirected run keeps its answer as + text, while a NAMED window is what was asked for and a window is not + stdout. + """ + at_a_desk = dict(term="qt", tty=True) + redirected = dict(term="qt", tty=False) + no_window = dict(term=None, tty=True) + check("default at a desk", "qt", g.choose_terminal(**at_a_desk)) + check("default redirected", "dumb", g.choose_terminal(**redirected)) + check("no interactive terminal", "dumb", g.choose_terminal(**no_window)) + check("named window survives a pipe", "qt", + g.choose_terminal(window=True, **redirected)) + check("named ascii at a desk", "dumb", + g.choose_terminal(want_ascii=True, **at_a_desk)) + check("a file needs no window", "pngcairo", + g.choose_terminal(png="f.png", **no_window)) + check("svg likewise", "svg", g.choose_terminal(svg="f.svg", **no_window)) + + +def test_a_window_that_cannot_be_drawn_is_refused_not_downgraded(): + """Naming it is asking for it, so ASCII instead would answer a + question nobody asked. The message has to name BOTH things that + could be missing — a display and a gnuplot built for one — because + either alone looks the same from here.""" + raises("no terminal", lambda: g.choose_terminal(window=True, term=None, + tty=True), + "no interactive gnuplot terminal") + raises("names gnuplot-nox", lambda: g.choose_terminal(window=True, + term=None, tty=True), + "gnuplot-nox") + + +def test_two_places_to_draw_is_refused_rather_than_one_winning(): + """`--window --png f` used to write the file and no window; `--png` + beside `--svg` quietly preferred the png. A statement whose + destination is picked for it means something other than what it + says, so both are named and neither wins.""" + raises("window and ascii", + lambda: g.choose_terminal(window=True, want_ascii=True, term="qt"), + "different places to draw") + raises("png and svg", + lambda: g.choose_terminal(png="a.png", svg="b.svg", term="qt"), + "different places to draw") + raises("names the flags it was given", + lambda: g.choose_terminal(window=True, png="a.png", term="qt", + spell="--"), + "`--window` and `--png`") + + def test_a_line_says_what_it_holds(): s = g.parse_line( '2026-09-06T13:37:00.000Z 60s m a=1 msg="two words" count=3 sum=1.5 @40+9' diff --git a/tests/timbersh/fake-gnuplot b/tests/timbersh/fake-gnuplot new file mode 100755 index 0000000..5528f64 --- /dev/null +++ b/tests/timbersh/fake-gnuplot @@ -0,0 +1,19 @@ +#!/bin/sh +# A gnuplot that exists but draws nothing, so a test can decide what +# this machine can do rather than inherit it. +# +# $FAKE_TERMS is the terminal list `set term` reports (space separated). +# It prints which of the two it was asked for: -p — which timbergraph +# passes only for an interactive terminal — is a WINDOW, anything else is +# text on stdout. So a test asserts what WAS chosen rather than the +# absence of the other, which would also pass if nothing ran at all. +case "$1" in + -e) [ "$2" = "set term" ] && { + echo "" >&2 + echo "Available terminal types:" >&2 + for t in ${FAKE_TERMS:-dumb}; do echo " $t a fake" >&2; done + } + exit 0 ;; + -p) cat >/dev/null; echo "FAKE-GNUPLOT-WINDOW"; exit 0 ;; + *) cat >/dev/null; echo "FAKE-GNUPLOT-TEXT"; exit 0 ;; +esac diff --git a/tests/timbersh/test-timbersh b/tests/timbersh/test-timbersh index b8a05bd..d722579 100755 --- a/tests/timbersh/test-timbersh +++ b/tests/timbersh/test-timbersh @@ -29,7 +29,14 @@ ID_B = "bbbbbbbb-1111-2222-3333-444444444444" class Session: """One timbersh run against a scripted server.""" - def __init__(self, scenario, hosts=None, extra=(), resolver=False): + def __init__(self, scenario, hosts=None, extra=(), resolver=False, + env=None): + # `env` overrides the inherited environment per key; None as a + # value UNSETS one. Which is what lets a test say whether a + # display exists — `graph` draws a window where one can be drawn, + # so a suite that inherited the developer's DISPLAY would assert + # different things on a desk and in CI. + self.env = env or {} self.dir = tempfile.mkdtemp(prefix="timbersh-test-") json.dump(scenario, open(os.path.join(self.dir, "scenario.json"), "w")) self.hosts = hosts @@ -90,7 +97,9 @@ class Session: try: p = subprocess.run(cmd, input="\n".join(statements) + "\n\\q\n", capture_output=True, text=True, timeout=60, - env={**os.environ, "FAKE_DIR": self.dir}) + env={k: v for k, v in + {**os.environ, "FAKE_DIR": self.dir, + **self.env}.items() if v is not None}) except subprocess.TimeoutExpired as e: # `text=True` does not reach here: what was captured before the # timeout comes back as bytes. @@ -166,7 +175,9 @@ class Pty(Session): "--histfile", os.path.join(self.dir, "history"), "--rc", os.path.join(self.dir, "nonexistent-rc")] cmd += self.where() + self.extra - env = {**os.environ, "FAKE_DIR": self.dir, "TERM": "xterm"} + env = {k: v for k, v in + {**os.environ, "FAKE_DIR": self.dir, "TERM": "xterm", + **self.env}.items() if v is not None} self.pid, self.fd = pty.fork() if self.pid == 0: os.execve(cmd[0], cmd, env) @@ -457,6 +468,139 @@ def a_graph_makes_the_fleet_a_dimension_rather_than_merging_it(): s.cleanup() +def with_gnuplot(terms): + """A PATH whose `gnuplot` is the fake, reporting `terms` as the + terminals it has. Which is the only way to test the choice: a real + window needs a real display, so CI would assert the opposite of a + desk and neither would be testing the decision. + + ⚠ A DIRECTORY of one binary, prepended — not the whole of tests/, + which holds `fake-timberfs` and would shadow the real one. + """ + d = tempfile.mkdtemp(prefix="timbersh-gnuplot-") + shutil.copy(os.path.join(HERE, "fake-gnuplot"), + os.path.join(d, "gnuplot")) + return {"PATH": d + os.pathsep + os.environ["PATH"], + "FAKE_TERMS": terms} + + +@test +def a_graph_draws_a_window_by_default_where_one_can_be_drawn(): + """The default is a window where one can be drawn, and that decision + is what this pins: a display, a gnuplot reporting an interactive + terminal, and somebody watching a terminal. The fake prints its + marker only when asked for one with `-p`, so this fails if `dumb` + was chosen. + + ⚠ On a PTY, because the default is gated on stdout being one — over + a pipe the answer is the text and a window would steal it.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + s = Pty({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env={**with_gnuplot("qt dumb"), "DISPLAY": ":99"}).start() + s.send("graph heap from [class=tally];\n") + s.expect("FAKE-GNUPLOT-WINDOW") + s.finish().cleanup() + + +@test +def a_redirected_run_keeps_its_answer_rather_than_losing_it_to_a_window(): + """The suite itself is the case: stdout is a pipe. A DEFAULT window + there would turn `timbersh -q < script > out.txt` at a desk into an + empty file, so the default is a window only where somebody is + watching one. Naming `window` still gets one -- a window is not + stdout, and asking for it with the output redirected is coherent.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + env = {**with_gnuplot("qt dumb"), "DISPLAY": ":99"} + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env=env).run("graph heap from [class=tally];") + assert "FAKE-GNUPLOT-TEXT" in s.out, s.out + s.cleanup() + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env=env).run("graph heap from [class=tally] window;") + assert "FAKE-GNUPLOT-WINDOW" in s.out, s.out + s.cleanup() + + +@test +def a_gnuplot_with_no_interactive_terminal_draws_ascii_though_a_display_exists(): + """`gnuplot-nox` is the case a DISPLAY check alone gets wrong: the + display is there and the terminal is not, so a window cannot be + drawn and asking for one is an error rather than a picture.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + env = {**with_gnuplot("dumb svg pngcairo"), "DISPLAY": ":99"} + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env=env).run("graph heap from [class=tally];") + assert "FAKE-GNUPLOT-TEXT" in s.out, s.out + s.cleanup() + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env=env).run("graph heap from [class=tally] window;") + assert "no interactive gnuplot terminal" in s.out, s.out + s.cleanup() + + +@test +def a_graph_draws_ascii_where_no_window_can_be_drawn(): + """The default is a WINDOW where one can be drawn and ASCII where it + cannot, so a session over ssh still answers. With no display there is + nothing to put a window on, whatever gnuplot can do.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env={"DISPLAY": None, "WAYLAND_DISPLAY": None}).run( + "graph heap from [class=tally];") + # The dumb terminal draws with characters; a window writes nothing to + # stdout at all. + assert "heap" in s.out, s.out + assert "no interactive gnuplot terminal" not in s.out, s.out + s.cleanup() + + +@test +def a_named_window_that_cannot_be_drawn_is_refused_not_downgraded(): + """Saying `window` is asking for one. Falling back to ASCII there + would answer a question nobody asked and hide the reason — the two + things needed are a display and a gnuplot built with an interactive + terminal, and the message has to name both.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env={"DISPLAY": None, "WAYLAND_DISPLAY": None}).run( + "graph heap from [class=tally] window;") + assert "no interactive gnuplot terminal" in s.out, s.out + assert "gnuplot-nox" in s.out, s.out + s.cleanup() + + +@test +def two_places_to_draw_is_refused_rather_than_one_of_them_winning(): + """`window png 'f'` named a file and a window; the file won and the + window was dropped in silence. Two destinations is a statement that + means something other than what it says, so it is refused and both + are named.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + for stmt, want in (("window ascii", ("window", "ascii")), + ("window png 'f.png'", ("window", "png")), + ("png 'f.png' svg 'f.svg'", ("png", "svg"))): + s = Session({"": {"stores": [store("t", ID_A)], + "loglines": gauge}}).run( + f"graph heap from [class=tally] {stmt};") + assert "different places to draw" in s.out, (stmt, s.out) + for w in want: + assert f"`{w}`" in s.out, (stmt, w, s.out) + s.cleanup() + + +@test +def ascii_is_sayable_where_a_window_would_otherwise_be_drawn(): + """The inverse of the default: at a desk, one graph wanted as text + to paste somewhere. It must not depend on unsetting a variable.""" + gauge = "2026-09-06T13:37:00.000Z 60s heap last=100\n" + s = Session({"": {"stores": [store("t", ID_A)], "loglines": gauge}}, + env={"DISPLAY": ":99"}).run( + "graph heap from [class=tally] ascii;") + assert "heap" in s.out, s.out + assert "different places" not in s.out, s.out + s.cleanup() + + @test def a_graph_refuses_a_host_label_it_would_have_to_write_over(): """The shell adds `host` itself, so a metric that already carries one diff --git a/tools/timbergraph.py b/tools/timbergraph.py index d367b5d..b8ece6c 100644 --- a/tools/timbergraph.py +++ b/tools/timbergraph.py @@ -813,6 +813,95 @@ def gp_quote(text): return "'" + str(text).replace("'", "''") + "'" +#: Interactive gnuplot terminals, best first. `aqua` is macOS's. +WINDOW_TERMINALS = ("qt", "wxt", "x11", "aqua") + +_window_term = "unprobed" + + +def window_terminal(): + """The interactive gnuplot terminal available here, or None. + + Two facts are needed and neither implies the other: a DISPLAY to put + a window on, and a gnuplot built with an interactive terminal — + `gnuplot-nox` has none, so it draws files and ASCII and warns on + `set terminal qt`. + + ⚠ Read from the TERMINAL LIST, not from gnuplot's exit code: `set + terminal nosuchterm` exits 0 and only warns on stderr, so a probe + that trusted the status would call every terminal available. The + list costs one gnuplot at ~10 ms and is cached, this being a fact + about the machine rather than about a plot. + """ + global _window_term + if _window_term != "unprobed": + return _window_term + _window_term = None + if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"): + exe = shutil.which("gnuplot") + if exe: + try: + out = subprocess.run([exe, "-e", "set term"], text=True, + capture_output=True, check=False, + timeout=10) + have = {ln.split()[0] for ln in + (out.stderr + out.stdout).splitlines() if ln.split()} + _window_term = next( + (w for w in WINDOW_TERMINALS if w in have), None) + except Exception: + _window_term = None + return _window_term + + +def choose_terminal(png=None, svg=None, window=False, want_ascii=False, + tty=None, term="probe", spell=""): + """WHERE to draw, from what was asked and what is possible. + + One rule, called by the CLI and by timbersh's `graph`, because two + copies of it would drift and the drift would be silent: the same + words would mean different pictures depending which you typed it in. + + The default is a WINDOW, because ASCII cannot carry the detail — + five series an order of magnitude apart collapse into two rows of + characters, and one of them vanishes. It needs three things, none of + which implies another: a display, a gnuplot built with an + interactive terminal, and a TTY to be watching it. That last one + bounds the DEFAULT only: a redirected run keeps its answer as text + rather than losing it to a window nobody sees, while a NAMED window + is what was asked for and a window is not stdout. + + A named window that cannot be drawn is refused rather than + downgraded — the point of saying it is to get one. + """ + named = [n for n, on in (("window", window), ("ascii", want_ascii), + ("png", bool(png)), ("svg", bool(svg))) if on] + if len(named) > 1: + raise Bad( + "`" + "` and `".join(spell + n for n in named) + "` name " + "different places to draw — give one. Omit them all for a " + "window where one can be drawn and ASCII where it cannot") + if png: + return "pngcairo" + if svg: + return "svg" + if want_ascii: + return "dumb" + if term == "probe": + term = window_terminal() + if window: + if not term: + raise Bad( + "no interactive gnuplot terminal here — a window needs a " + "display (DISPLAY or WAYLAND_DISPLAY) and a gnuplot built " + "with one (`apt install gnuplot-qt`; gnuplot-nox has none). " + f"Drop `{spell}window` for ASCII, or " + f"`{spell}png`/`{spell}svg` for a file") + return term + if tty is None: + tty = sys.stdout.isatty() + return term if (term and tty) else "dumb" + + def draw(text, terminal): """Run gnuplot, or say what to install. ⚠ A SOFT dependency: the tools carry no third-party imports and nothing here should be the @@ -825,7 +914,7 @@ def draw(text, terminal): "window needs gnuplot-qt or gnuplot-x11)" ) proc = subprocess.run( - [exe, "-p" if terminal in ("qt", "x11", "wxt") else "-"], + [exe, "-p" if terminal in WINDOW_TERMINALS else "-"], input=text, text=True, capture_output=True, check=False, ) if proc.returncode != 0: @@ -927,7 +1016,11 @@ def main(argv=None): ap.add_argument("--png", metavar="FILE") ap.add_argument("--svg", metavar="FILE") ap.add_argument("--window", action="store_true", - help="an interactive window; needs a display and gnuplot-qt") + help="force an interactive window; needs a display and " + "gnuplot-qt (the default already draws one where it " + "can)") + ap.add_argument("--ascii", action="store_true", + help="force characters on the terminal instead of a window") ap.add_argument("--width", type=int, help="characters on the terminal, pixels in an image") ap.add_argument("--height", type=int) @@ -1010,13 +1103,13 @@ def main(argv=None): and s.labels.get("metric") in metrics] dest = args.png or args.svg - terminal = ("pngcairo" if args.png else "svg" if args.svg - else "qt" if args.window else "dumb") + terminal = choose_terminal(png=args.png, svg=args.svg, window=args.window, + want_ascii=args.ascii, spell="--") if terminal == "dumb": size = (args.width or terminal_size()[0], args.height or terminal_size()[1]) else: - size = (args.width or (900 if args.window else 1000), + size = (args.width or (900 if terminal in WINDOW_TERMINALS else 1000), args.height or 500) if args.against: text_out = scatter(lines, title, xlabel, ylabel, terminal, size, dest) diff --git a/tools/timbersh b/tools/timbersh index a7c6358..6fd6b5d 100755 --- a/tools/timbersh +++ b/tools/timbersh @@ -197,7 +197,8 @@ HELP = """ select from [where ...] [limit N [chunks]] [into view]; tail from [where ...]; graph [, ...] from [by LABEL] [where ...] - [quantile Q] [rate] [using ] [png 'FILE'|svg 'FILE']; + [quantile Q] [rate] [using ] + [window|ascii|png 'FILE'|svg 'FILE']; graph against from [...]; graph from extracting where ... ; @@ -234,8 +235,18 @@ HELP = """ takes for one, so `by host` keeps them apart and leaving it out sums them on purpose. `quantile 0.95` of a cumulative `le` ladder -- summing one multiplies the count, so it is refused; `rate` divides by - the bucket width; `png 'f'`/`svg 'f'` draw somewhere other than the - terminal. Needs gnuplot. + the bucket width. Needs gnuplot. + + WHERE IT DRAWS, and by default that is a WINDOW where one can be + drawn and ASCII where it cannot -- so the same statement gives a + picture at a desk and still answers over ssh. A window needs a + display and a gnuplot built with an interactive terminal, which + `gnuplot-nox` is not -- and, for the DEFAULT only, a terminal to be + watching it, so a redirected run keeps its answer as text instead of + losing it to a window. `window` and `ascii` say which outright, and a + `window` that cannot be drawn is refused rather than downgraded; + `png 'f'`/`svg 'f'` write a file instead -- timbersh does not open + it. One of the four at a time. records | loglines | stores | chunks a view name, or a predicate literal like [type=console]. @@ -779,7 +790,8 @@ def show_window(session): #: What ends a `where` in a `graph`. A select's clauses are `limit` and #: `into`; a graph has its own, and without them here `quantile` after a #: `where` was read as a condition and refused. -GRAPH_CLAUSES = ("by", "quantile", "rate", "png", "svg", "using", "extracting") +GRAPH_CLAUSES = ("by", "quantile", "rate", "png", "svg", "using", "extracting", + "window", "ascii") def parse_where(toks, i, stop=()): @@ -974,7 +986,8 @@ class Complete: return AFTER_TIME low = [t.lower() for t in rest] once = [w for w in ("where", "quantile", "rate", "png", "svg", - "using", "extracting") if w not in low] + "using", "extracting", "window", "ascii") + if w not in low] return ["by"] + once + (["and"] if "where" in low else []) if slot == "": if prev in ("chunk", "offset"): @@ -1722,8 +1735,8 @@ class Shell: raise ValueError(f"`{w}` what?") if w == "by": by.append(toks[i + 1][1]); i += 2 - elif w == "rate": - opts["rate"] = True; i += 1 + elif w in ("rate", "window", "ascii"): + opts[w] = True; i += 1 elif w == "quantile": opts["quantile"] = float(toks[i + 1][1]); i += 2 elif w in ("png", "svg", "using", "extracting"): @@ -1732,7 +1745,26 @@ class Shell: raise ValueError( f"`{toks[i][1]}`? after the source come `by LABEL`, " f"`where ...`, `quantile Q`, `rate`, `using PATH`, " - f"`extracting NAME` and `png FILE`/`svg FILE`") + f"`extracting NAME`, `png FILE`/`svg FILE`, and " + f"`window`/`ascii`") + + # ⚠ WHERE to draw, decided BEFORE the read and by timbergraph's + # own rule rather than a second copy of it — the same words must + # mean the same picture whichever of the two you typed them in. + # Before the read, because a statement naming two places is + # refused and there is no reason to spend a fleet query on it + # first. + # + # ⚠ `Bad` is not a ValueError, and only ValueError reaches the + # loop's handler — so a refusal left untranslated arrives as a + # traceback out of the shell rather than as a message. + try: + terminal = timbergraph.choose_terminal( + png=opts.get("png"), svg=opts.get("svg"), + window=opts.get("window", False), + want_ascii=opts.get("ascii", False)) + except timbergraph.Bad as e: + raise ValueError(str(e)) from None extracting = opts.get("extracting") if extracting: @@ -1817,8 +1849,9 @@ class Shell: and m.labels.get("metric") in metrics] dest = opts.get("png") or opts.get("svg") - terminal = "pngcairo" if "png" in opts else "svg" if "svg" in opts else "dumb" - size = ((1000, 500) if dest else timbergraph.terminal_size()) + size = ((1000, 500) if dest + else (900, 500) if terminal in timbergraph.WINDOW_TERMINALS + else timbergraph.terminal_size()) if scatter_x: text = timbergraph.scatter(lines, title, xlabel, ylabel, terminal, size, dest) From a44328be8d46033a7256a6de2862b680df7134f0 Mon Sep 17 00:00:00 2001 From: Torstein Tauno Svendsen Date: Mon, 7 Sep 2026 21:51:05 +0200 Subject: [PATCH 2/2] The probe must not read the terminal it is asking about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `graph ... window` refused on a machine with gnuplot-qt installed and WAYLAND_DISPLAY set. `gnuplot -e CMD` runs CMD and then goes on READING STDIN, and `capture_output=True` does not touch stdin — so inside an interactive shell the probe inherited the user's terminal, waited there for the full timeout, and the fallback reported "no interactive terminal" about a machine that has one. It also ate whatever was typed at it meanwhile. `stdin=subprocess.DEVNULL` is the whole fix; measured on a real pty, 10 s and None becomes 0.02 s and `qt`. ⚠ NO TEST COULD HAVE SEEN THIS. Every suite pipes stdin, where gnuplot reaches EOF at once and the probe is correct. So `fake-gnuplot` now reads stdin to EOF after answering `set term`, which is what the real one does in the respect that matters — and the default-window test, which already ran on a PTY, fails without the fix. The bare `except Exception` around the probe is narrowed to OSError and SubprocessError. Falling back to ASCII is the safe direction, but swallowing every exception is what turned a hang into a confident wrong answer about the machine. Co-Authored-By: Claude Opus 5 (1M context) --- tests/timbersh/fake-gnuplot | 6 ++++++ tools/timbergraph.py | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/timbersh/fake-gnuplot b/tests/timbersh/fake-gnuplot index 5528f64..68efdeb 100755 --- a/tests/timbersh/fake-gnuplot +++ b/tests/timbersh/fake-gnuplot @@ -13,6 +13,12 @@ case "$1" in echo "Available terminal types:" >&2 for t in ${FAKE_TERMS:-dumb}; do echo " $t a fake" >&2; done } + # ⚠ Then READ STDIN to EOF, because that is what gnuplot does: -e + # runs the command and carries on reading. A fake that exited here + # would probe instantly on a terminal, where the real one waits for + # input — and the probe running without stdin redirected is exactly + # the defect this fidelity exists to catch. + cat >/dev/null exit 0 ;; -p) cat >/dev/null; echo "FAKE-GNUPLOT-WINDOW"; exit 0 ;; *) cat >/dev/null; echo "FAKE-GNUPLOT-TEXT"; exit 0 ;; diff --git a/tools/timbergraph.py b/tools/timbergraph.py index b8ece6c..8f3a36c 100644 --- a/tools/timbergraph.py +++ b/tools/timbergraph.py @@ -841,14 +841,26 @@ def window_terminal(): exe = shutil.which("gnuplot") if exe: try: + # ⚠ stdin=DEVNULL, and it is the whole of this working. + # `gnuplot -e CMD` runs CMD and then goes on READING + # STDIN, and `capture_output` does not touch stdin — so + # inside an interactive shell the probe inherited the + # user's terminal, sat there until the timeout, and the + # fallback below reported "no interactive terminal" on a + # machine with gnuplot-qt installed and a display. It + # also ate the keystrokes typed at it meanwhile. Every + # test piped stdin, so none of them could see it. out = subprocess.run([exe, "-e", "set term"], text=True, capture_output=True, check=False, - timeout=10) + stdin=subprocess.DEVNULL, timeout=10) have = {ln.split()[0] for ln in (out.stderr + out.stdout).splitlines() if ln.split()} _window_term = next( (w for w in WINDOW_TERMINALS if w in have), None) - except Exception: + except (OSError, subprocess.SubprocessError): + # Fall back to ASCII, which is the safe direction — but + # narrowly, so a bug here is a traceback rather than a + # silent "this machine cannot draw". _window_term = None return _window_term