Skip to content

Commit 8d2c7c1

Browse files
borisbatclaude
andauthored
tune: a progress display for --tune, per-ISA [tuned] fallbacks, and arm64 cpu_supports (#3583)
* tune: design notes for progress events and a single renderer Captures the current four-level process shape, the buffered-relay blocker in run_scope_tuner, the event vocabulary, the renderer-vs-forwarder rule, the new daslib/tty binding, and the watchdog.py strings that are a live contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: route the flag rail through daslib/clargs in the design notes Records the four hand-rolled parsers to migrate, get_user_args as the accessor, the shared-argv precedent (ReleaseDepsArgs, same module), and the unknown-flag value-passthrough hazard that keeps the tune spec bool-only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: make gen_tune_probe honour --tune-fast a decided item in the notes The generator half silently drops the flag the tuner passes it. Records the round-count cut as the analogue of tune_kernels' screening cut, and why the confirm pass stays untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: record the supervised dasllama-server scenario as the goal Maps the tuning/restart/normal narrative onto the restart policy rail, which is already correct end to end; the two gaps are the up-front promise and the buffered silence. Adds the watchdog-as-renderer consequence and the STATE control-page surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: no duration estimates; add pass name and live-candidate count Everything displayed is observed, never predicted. Records the three honest axes beyond the outer counter, including the screening/narrowing/finalists passes tune_candidate_active already runs and the live count screening_keep already computes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: stream the scope tuner's output instead of buffering it to a file run_scope_tuner redirected the tuner to a temp file with an empty popen callback and printed the file after the child exited, so a minutes-long tune produced no signal at all until it finished. Relay live via fgets, the same shape tune_auto_reexec and dasllama_tuner already use. Also stops discarding the tuner's exit code: popen returns it, so report a non-zero rc and return it rather than an unconditional true. Both current callers ignore the result, so this changes no behavior today. Tests: llvm_tune_scope.das and llvm_tune_manifest.das, both under -jit (they short-circuit to pass without it). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * daslib/tty: terminal capability probes, with the fio_core binding behind them daslang had no way to ask whether a stream is a terminal — isatty appeared only in vendored doctest/fmt/gtest C++, and no width query existed at all. Sniffing TERM (what ansi_colors does for styling) is not a substitute: it stays set when stdout is a pipe or a log file, which is exactly the case a redrawn progress display must not fire in. fio_core gains is_terminal(fd) and terminal_width(); daslib/tty wraps them as named predicates plus terminal_columns(fallback), and does not re-export fio_core. Width falls back to COLUMNS, then to the caller's fallback. Verified: piped -> false/0; piped with COLUMNS=123 -> 123; attached to a real pty sized 137 cols -> true/137. AOT emission checked via -aot on daslib/tty.das (the daslib glob AOTs it, hence the aot_builtin_fio.h declarations). Tests: tests/daslib/tty_test.das (registered for AOT), plus tests/fio and tests/daslib green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: progress events, a single renderer, and the up-front promise The tuner runs as a chain of child processes, so progress travels as prefixed lines on stdout ("@tune kind k=v ...") — a pipe is the only channel spanning them. llvm_tune owns the vocabulary and the display, so any library's tuner gets it, not just dasLLAMA's. One rule decides who draws: a process renders when its OWN stdout is a terminal, and otherwise forwards events verbatim. Forwarding is unconditional; verbosity gates only what a human sees. That is what lets "silent" mean literally nothing on a terminal while a supervisor still receives every event. Raw tuner chatter is muted only while a display is actually live. A tuner that emits no events keeps printing exactly as before — the existing scope test caught the earlier rule, which had made adopting the protocol a prerequisite for being visible at all. No duration is ever shown, estimated or otherwise; the segments are too uneven to predict. The counters are outer kernels, inner rounds, the pass name (screening/narrowing/finalists) and the live-candidate count, plus observed elapsed. Adds the up-front promise: an untuned start now says a tune takes a while and ends in a restart BEFORE the wait, and says whether the process restarts itself (auto) or must be restarted (restart policy). tune_progress_line/_feed/_active/_reset are public so another front end can render the same state — and so the protocol is testable without owning a tty. Tests: modules/dasLLVM/tests/llvm_tune_progress.das (9 cases, no tty needed); whole dasLLVM suite green under -jit. Verified end to end against a real pty: pipe forwards 19 events and draws 0 bars, pty draws 19 and forwards 0, silent draws nothing on the pty yet still forwards all 19 under a pipe, verbose alone shows raw chatter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: mark the built sections in the design notes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: clargs flag rail, --tune-fast for the generator half, and real emitters The flags now go through daslib/clargs everywhere, replacing three hand-rolled argv scans. llvm_tune parses the APP's argv it does not own, so it uses the two-arg parse_args form (the ReleaseDepsArgs precedent in this same module); its spec is bool-only and never reads positionals, because clargs skips an unknown flag without consuming its value and an app's `--model foo.gguf` thus leaves `foo.gguf` in the positional list. get_user_args replaces the hand-rolled post-`--` walk and also gets the standalone-exe slice right. --tune-quiet / --tune-verbose set DAS_TUNE_VERBOSITY, which inherits down the whole tuner process chain. They are adopted at macro time, so the value has to travel by environment: the runtime guard is a different context and would not see this one's global. gen_tune_probe never parsed --tune-fast although dasllama_tuner passed it to both halves and bannered "FAST". It now honours it, ROUNDS 6 -> 3 — the flat best-of-N has no screening phase to narrow, so the round count is the only dial. The confirm pass is deliberately untouched: it is a correctness gate, and cutting it ships an unconfirmed crown. The banner no longer advertises tune_kernels' budget as though it covered both halves. The three consumers share one flag spec, harness/tuner_cli.das, required by bare name; the producer side (daspkg) was already clargs. Both halves now emit progress events. tune_kernels reports the pass and the live-candidate count that screening_keep already computes; gen_tune_probe gives the end-to-end confirm its own visible step so it is not an invisible stall. Verified by running both tuners for real: tune_kernels --tune-fast -> plan total=25, 25 begin/end, 500 steps (25 x 20 rounds), phases split 100 screening / 100 narrowing / 300 finalists — exactly fast mode's 4/8/20, which also proves the clargs migration took — live counts narrowing 20 -> 12 -> 4, verdicts 18 beats / 7 holds. gen_tune_probe --tune-fast -> plan total=7, per-family total=3 (the 6 -> 3 cut). Docs: skills/tune.md (what a tune shows + the event protocol), skills/environment_variables.md (DAS_TUNE_VERBOSITY), skills/llvm_tune.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: keep event values space-free, and test the truncation The wire format is k=v split on spaces, so gen_tune_probe's "confirm (end-to-end prefill)" step name truncated to "confirm" and left two junk tokens. It degraded rather than corrupted — the display and counters were fine — but it broke the contract, so the name is now confirm_e2e_prefill and the contract is stated where the prefix is defined. Found by reading the real emitted stream rather than by a test, so a test now pins the degradation shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * watchdog: consume tune progress events instead of inferring them from prose A supervised child never owns a terminal, so llvm_tune forwards its @tune events rather than drawing a bar — which is exactly what makes them the watchdog's to consume. They fold into STATE["tune"] for the control plugin's status route, and log only at plan/end: a real tune is hundreds of steps, and logging each would recreate the per-variant flood these events exist to replace. Measured, that is 26 log records from 551 events, where the whole raw tuner output used to be JSON-wrapped line by line. This is the part of STARTUP_STAGES that no longer has to regex human prose. The rest still does, and both load-bearing strings are untouched, so a deployed old watchdog meeting a new daslang (and the reverse) still works — verified that both stage regexes still match. Every published field is a counter. Nothing is an estimate, and the README says not to synthesize one: the tuner cannot know how long it has left. Verified by replaying both real captured tuner streams through the new parser: done lands exactly on total for each (25/25 kernels, 7/7 generator families), done never exceeds total at any snapshot, malformed input never raises, and a prose line is never mistaken for an event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: render from the emitter too, and clear llvm_tune's lint debt Running a tuner half straight from a terminal spewed raw @tune lines instead of a bar, because only the relay rendered. Emitting now goes through the same relay, so the rule is uniform: any process owning a terminal draws, any other forwards. Verified under a pty — 0 raw event lines leaked, 551 bar frames. tune_progress_finish() closes the display; both halves call it. llvm_tune.das lint 49 -> 0, since a PR has to be clean and the rest of daslib already is (143 files, 0 issues). 8x LINT016: `errors :=` on a plain `string&` promised a clone it does not perform. Only the two string& functions were touched — the nine das_string ones use := correctly and were left alone. 41x STYLE014/STYLE015: public //! docs trimmed to what they document, with the deep semantics already carried by skills/tune.md and skills/llvm_tune.md; internal // blocks cut to the one WHY a reader cannot get from the code. No nolint markers — the rule's escape exists but the tree essentially does not use it (3 files), so complying is the honest fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: mute the tuner's own chatter under a live display The display only replaced output that flowed through the relay. A tuner half run directly has no relay above it, so report()'s per-variant lines and the checksum lines went straight to stdout and trampled the bar, and the run ended by fread-ing the whole sidecar back — the "output mainly duplicates json" complaint, still intact. tune_detail() is the gate for a tuner's own measurement lines: shown under verbose, or whenever no display is live, so a tuner that emits no progress events still prints exactly as before. Both halves route their chatter through it; the summary block, hard failures and the "wrote <path>" line stay unconditional, and the sidecar body is now verbose-only. finish_progress no longer emits a newline when nothing was drawn — under a pipe the state is live but no bar exists, so it was adding a stray blank line. Measured on a real terminal at normal verbosity: per-variant chatter 0 (was ~500), checksum lines 0, sidecar json 0, 551 bar frames, 31 lines of tuner output total. Note the ~100 `loop not vectorized` lines around a JIT tune are LLVM's own diagnostics on stderr, not this rail: the [tuned] grid deliberately requests vectorization widths LLVM cannot always honour. stdout carries none of them, so 2>/dev/null leaves a clean display. Quieting them needs an LLVM diagnostic handler and is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: the per-kernel stamp report is verbose-only A real tune printed 34 `dasllama_tune: <kernel> <- <perm>` lines (plus the llvm_tune twin) before anything else — the compile reporting every stamp it made. That is a wall in front of an app's own startup, and it says the same thing log_tune_status() says on request and the closing summary says at the end. Worse, side by side the two disagree by design: the stamp list is what THIS compile used, the summary is what the run just measured. Both are gated to verbose now. The two tests that read those lines as their stamped-truth proof pass DAS_TUNE_VERBOSITY=verbose to their children; in llvm_tune_manifest that also lets the env prefix lead, so the windows sacrificial-quote wrap is no longer needed. On a terminal a full --tune-fast run is now: 4 JIT lines, the tuner banner, one live bar, the summary, and "wrote <path>". Measured: 551 bar frames, 0 raw events, 0 stamp lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: per-ISA [tuned] fallbacks, and make cpu_supports answer on arm64 [tuned]'s fallback= was a single string — one generic default for every target — while [tune]'s was already a per-ISA `;`-chain. That asymmetry is why 18 of 25 kernels beat their shipped default on an M1: a single guess cannot be right for both ARM and x64, and an untuned box (CI, AOT, a release with no sidecar) just ate the miss. fallback= is now a `;`-chain of `suffix` or `suffix:requires` entries, first passing entry wins, so ONE annotation ships a different default per ISA. Its perms are generated and have no row to hang requires= on, hence the inline form; [tune]'s own chain is unchanged. A bare name is a one-entry chain, so every existing annotation behaves exactly as before. The requires= evaluator moves to llvm_tune as public tune_requires_ok(expr) + tune_pick_fallback(chain), so both halves share one implementation of the `,`=AND / `|`=OR grammar and the DAS_JIT_*_FORCE_FEATURES overrides. PREREQUISITE, not scope creep: cpu_supports was x86-only and returned false for every name on arm64 — including neon. Built on that, the feature would have been dead on the platform that motivated it, always falling through to the generic entry. It now answers on arm64 via sysctl (macOS), AT_HWCAP (Linux) and IsProcessorFeaturePresent (Windows), using LLVM target-feature spellings so the names match what DAS_JIT_ARM64_FORCE_FEATURES and `llc -mattr` take. Unknown and wrong-architecture names stay fail-closed. Verified on an M1 Max against sysctl ground truth: dotprod/fullfp16/lse/rdm/ crc/sha3 true, i8mm/bf16/sve false, x86 names false. End to end on a real kernel, three ways: dotprod present picks the arm row; gated on i8mm (absent here) falls through to the generic row; DAS_JIT_ARM64_FORCE_FEATURES=i8mm picks the arm row again. resolve_perm now reports every path at verbose (sidecar / fallback / default), not just sidecar hits — the fallback decision was previously unobservable. Tests: modules/dasLLVM/tests/llvm_tune_requires.das (9 cases, host-independent); tests/language/cpu_supports.das gains arm64 coverage and loses a comment that this change made false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune_kernels: derive the summary from report() instead of parallel arrays The shipped-fallback fact lived in three hand-maintained copies: the [tuned(fallback=)] annotation, report()'s baseline argument at 23 call sites, and the kfall array. Two of them were display-only; report()'s baseline is not — a sub-0.5% win reverts to it, and that decides what lands in the sidecar. report() is called exactly once per kernel and already holds all three values, so it now records (kernel, winner, fallback) in call order and the summary reads that back. kfall, knames and kwin — three parallel 25-entry arrays — are gone, and the summary can no longer disagree with the decision it reports, because it IS that value. All three copies agreed before this change (verified across all 25 kernels), so this is a refactor with no behavioural delta: same 25 lines, same order, same note logic. The winner locals stay — they still feed the sidecar table, which carries extra semantics the summary does not (softmax_sink mirrors softmax, plus explicit entries for kernels this half does not sweep). TUNED_KERNEL_COUNT stays hand-written: it is the progress denominator and is needed before the first kernel runs, so it cannot come from the results. main now compares it against the rows actually collected and says so if they differ, rather than letting the bar quietly misreport. The remaining duplicate is annotation <-> report baseline. That one has to close before any kernel adopts a per-ISA fallback chain, since a chain resolves per box and a literal baseline argument cannot follow it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune_kernels: derive the shipped fallback from the annotation, not literals The last duplicate. report()'s baseline was 23 hand-written literals restating the [tuned(fallback=)] annotations, and it is not cosmetic: a sub-0.5% win reverts to the baseline, so a wrong one changes what ships. With a per-ISA chain the literal could not be right at all — a chain resolves per box. [tuned] now banks each kernel's resolved fallback and [dasllama_fallbacks] emits dasllama_tuned_fallbacks() from that bank; report() looks it up. All 23 literals are gone. Verified the derived values equal the removed literals for all 25 swept kernels, and the registry carries all 34 [tuned] kernels. Probed the module-hop risk before building on it: a bank filled during the math modules' compile IS visible to a macro applied in tune_kernels (34 entries), because both are the same macro module. The per-requiring-module-copy trap llvm_tune documents applies across macro modules, not within one. Proven with a real chain on rmsnorm: gated on dotprod (present here) the summary reports the fallback as vec16; gated on i8mm (absent) it reports plain. Both said "plain" under the literals, and the noise-floor guard would have judged against the wrong default. Also: `explicit` is a reserved word (DAS_EXPLICIT, the generic-parameter modifier) — hit while naming a local. Added to CLAUDE.md's reserved list, which had label/expect/pass but not this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: wire daslib/tty and the new fio terminal builtins into das2rst A new daslib module and two new fio externs both trigger doc.yml. Registers the module (require + document_module_tty + the call + the sec_io toctree + the stdlib index) and groups is_terminal/terminal_width under fio's new "Terminal queries" heading, since an ungrouped public function lands in Uncategorized and fails CI. Fills the three handmade stubs das2rst generated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: hoist the arm64 feature includes out of namespace das The arm64 detection block put <sys/sysctl.h>, <sys/auxv.h> and <asm/hwcap.h> inside `namespace das` — the namespace opens at the top of the file and the block sits ~1950 lines in. It built here only because macOS takes the __APPLE__ branch and clang tolerated it; the linux branch pulls two more headers into das::, and a mac-only syntax pass never compiles that path at all. Headers and the HWCAP_* fallback defines move to file scope; the helper functions stay where they were. No behavioural change — same declarations, same macros, just not injected into the namespace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: archive the tune progress-UX design notes The arc ships with this branch, so per skills/doc_archiving.md the plan doc moves to history/compiler/ rather than sitting stale in a live module dir. Its operative content is now skills/tune.md (user-facing) and skills/llvm_tune.md (internals); what remains is the record of why it is shaped this way. Ledger line added to history/README.md; no inbound references to the old path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: include windows.h for the arm64 Windows cpu_supports branch The _M_ARM64 path calls IsProcessorFeaturePresent with PF_ARM_* constants, but nothing in this TU's include graph pulls windows.h in — include/daScript/ does not include it anywhere — so an arm64 Windows build would not compile. CI has no windows-arm64 lane, so this would have shipped latent. Guarded to arm64 + _WIN32, with NOMINMAX and WIN32_LEAN_AND_MEAN, so no lane CI actually builds sees any change. Caught by Copilot on #3583. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * watchdog: clear published tune state when a child starts stream_child kept `tune` local per child, but STATE["tune"] was only written when an event arrived — so the tune-bootstrap restart, which is the normal first-run path, left the control page showing the finished tune's counters for the rest of the run. The relaunched child is already tuned and emits no events at all, so nothing ever overwrote them. Publish an empty tune state as each child starts. Caught by Copilot on #3583. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune_kernels: close a kernel whose bench returned early bench_laneq4x4 returns before report() on non-arm64 or without -jit, and report() is the only place that emits the end event and records a result row. kernel_begin had already announced the kernel, so on those hosts the outer counter stopped one short of its plan for the rest of the run — the bar never completed, the summary silently dropped the row, the hand-written-count check misfired, and watchdog's STATE["tune"] never converged. Fixed in kernel_done rather than at the one call site: it brackets every kernel in main and runs even when the bench returns early, so any future skip path is covered by construction. Audited the other 22 bench functions — laneq4x4 is the only one with a non-report return. Skipped kernels now render as "skipped" instead of a blank winner, and `skipped` joins the documented verdict set. Caught by Copilot on #3583. Verified the normal -jit path is unchanged: 25 begin / 25 end against plan total=25, 25 summary rows, no count-drift note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: a new plan clears the previous scope's kernel state consume_event's plan branch set the outer counters but left kernel/phase/inner counters/live from the scope before it. One relay sees BOTH dasllama halves, so after the generator half ends, the kernel half's plan left the display showing "0/25 confirm_e2e_prefill" — and that is not a single frame: tune_kernels has ~30 lines of fixture setup between its plan and its first kernel_begin, so the wrong kernel name is on screen for all of it. plan now rebuilds the whole state, which is what a fresh scope means; begin already did the same for per-kernel fields. Test added for the two-plan sequence. Caught by Copilot on #3583. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * watchdog: clear the per-round fields when a kernel ends apply_tune_event's "end" cleared rounds/live but left round and phase from the last step, so between kernels STATE published an incoherent in-flight round — round=47 against rounds=0 — which a status page renders verbatim. The das renderer hides those fields when innerTotal is 0, but STATE has no such guard; it is read raw. Same class as the plan-reset fix in llvm_tune, and more visible here for that reason. Caught by Copilot on #3583. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * watchdog: a hostile @tune line must not kill the streaming thread emit(logger, "tune", pid=..., kind=kind, **fields) splats parsed fields as kwargs, so a line carrying pid= or kind= is a duplicate-keyword TypeError, and ts=/event= would silently shadow the log envelope. The child's stdout is arbitrary bytes — the watchdog also supervises non-daslang programs — so that input is allowed by contract, and parse_tune_event's docstring already promises never to raise on malformed input. The blast radius is bigger than a lost log line: stream_child runs in a daemon thread, so the exception kills it silently and the watchdog keeps running blind — no stage detection, no child log relay, and no tune_restart_seen, which is what tells exit 3 apart from a crash. A garbled line would have turned every subsequent tune bootstrap into a backoff restart. Reserved keys are dropped before the splat. Caught by Copilot on #3583. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * tune: a must-see line no longer collides with the live display report()'s "no passing variant" printed straight to stdout while the bar was live, so it landed on the bar's line and the next redraw wrote over it — the one message you must not lose was the one most likely to be destroyed. Routing it through tune_detail (the obvious fix) would be worse: that mutes under normal verbosity, so a hard failure would vanish entirely. Added tune_progress_note instead — it wipes the bar's line, prints the text, and leaves the display armed so the bar redraws underneath. Silent still suppresses. Verified on a pty by replaying the CR/LF stream the way a terminal does: k0: no passing variant [######--------------] 1/3 k1 finalists 2/4 4 live 0s Copilot also flagged the summary block for the same reason; that one is fine — it runs after tune_progress_finish() has already closed the display. Caught by Copilot on #3583. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent ef978ae commit 8d2c7c1

32 files changed

Lines changed: 1799 additions & 416 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ A generic that should accept `array<T>`, `array<array<T>>`, … (any nesting)
304304
- **`where` and `shared` are reserved words too**`where` is the comprehension filter keyword and
305305
`shared` the module modifier; both are a syntax error as a variable name (`let where = ...`,
306306
`let shared = ...`). Rename (`sink_pos`, `shared_node`); hit while writing PERF026-028
307-
- **`label`, `expect`, and `pass` are reserved words** (lexer keywords `DAS_LABEL`/`DAS_EXPECT`/`DAS_PASS``pass` is the no-op statement) — using any as a parameter/variable name is a syntax error; rename (`tag`, `want`, `cpass`)
307+
- **`label`, `expect`, `pass`, and `explicit` are reserved words** (lexer keywords `DAS_LABEL`/`DAS_EXPECT`/`DAS_PASS`/`DAS_EXPLICIT``pass` is the no-op statement, `explicit` the generic-parameter modifier you see in `array<T> explicit` signatures) — using any as a parameter/variable name is a syntax error; rename (`tag`, `want`, `cpass`, `declared`)
308308
- **`range`/`urange`/`range64`/`urange64` are lexer TYPE tokens** (`DAS_TRANGE` etc., like `int`) — unusable as struct-field, parameter, or annotation-argument names (`@range = ...` is a syntax error); the grammar whitelists them back to a plain name only in call position, which is why `range(10)` works. Rename (`rng`, `span`); grammar-verified 2026-07-23
309309
- **Literal `{`/`}` in string literals must be escaped `\{`/`\}`** — unescaped `{...}` is interpolation. Bites when embedding shader/C source as inline strings. String literals may span multiple lines (raw newlines are legal); probe-verified 2026-07-11
310310
- String builder requires `unsafe` or `options persistent_heap` if returned

daslib/tty.das

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
options gen2
2+
options indenting = 4
3+
options strict_smart_pointers = true
4+
5+
module tty shared public
6+
7+
//! Terminal capability probes — is a stream a real terminal, and how wide is it.
8+
//!
9+
//! This is the gate for anything that redraws itself with ``\r``: a progress bar written to a
10+
//! pipe, a redirect, or a supervising process is garbage in the resulting log. Sniffing ``TERM``
11+
//! is not enough — it stays set when stdout is redirected. ``daslib/ansi_colors`` handles
12+
//! styling; this module answers the capability question underneath it.
13+
14+
require fio_core
15+
16+
//! Standard stream numbers, for ``is_stream_terminal``.
17+
let TTY_STDIN = 0
18+
let TTY_STDOUT = 1
19+
let TTY_STDERR = 2
20+
21+
//! Width assumed by ``terminal_columns`` when nothing reports one.
22+
let TTY_DEFAULT_WIDTH = 80
23+
24+
def is_stream_terminal(fd : int) : bool {
25+
//! True when the standard stream `fd` (`TTY_STDIN` / `TTY_STDOUT` / `TTY_STDERR`) is
26+
//! attached to a terminal. Any other descriptor is false.
27+
return is_terminal(fd)
28+
}
29+
30+
def is_stdin_terminal() : bool {
31+
//! True when standard input is attached to a terminal.
32+
return is_terminal(TTY_STDIN)
33+
}
34+
35+
def is_stdout_terminal() : bool {
36+
//! True when standard output is attached to a terminal. This is the gate for a redrawn
37+
//! progress display — false under a pipe, a redirect, or a supervising process.
38+
return is_terminal(TTY_STDOUT)
39+
}
40+
41+
def is_stderr_terminal() : bool {
42+
//! True when standard error is attached to a terminal.
43+
return is_terminal(TTY_STDERR)
44+
}
45+
46+
def terminal_columns(fallback = TTY_DEFAULT_WIDTH) : int {
47+
//! Terminal width in columns, substituting `fallback` when neither the terminal nor
48+
//! ``COLUMNS`` reports one. Pass `fallback = 0` for the raw probe.
49+
let w = terminal_width()
50+
return w > 0 ? w : fallback
51+
}

doc/reflections/das2rst.das

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ require daslib/regex_boost
2727
require daslib/apply
2828
require daslib/algorithm
2929
require daslib/ansi_colors
30+
require daslib/tty
3031
require daslib/jobque_boost
3132
require daslib/jobque_profile
3233
require daslib/apply_in_context
@@ -220,6 +221,7 @@ def document_module_fio(_root : string) {
220221
group_by_regex("Directory manipulation", mod, %regex~(dir|dir_rec|mkdir|mkdir_rec|mkdir_result|rmdir|rmdir_rec|rmdir_rec_result|rmdir_result|chdir|getcwd)$%%),
221222
group_by_regex("Glob and pattern matching", mod, %regex~(match_glob|glob|glob_filtered|is_glob_pattern|expand_glob|parse_file_list)$%%),
222223
group_by_regex("Filesystem queries", mod, %regex~(temp_directory|temp_directory_result|create_temp_file|create_temp_file_result|create_temp_directory|create_temp_directory_result|disk_space)$%%),
224+
group_by_regex("Terminal queries", mod, %regex~(is_terminal|terminal_width)$%%),
223225
group_by_regex("OS specific routines", mod, %regex~(sleep|exit|system|popen|popen_binary|popen_timeout|spawn_argv|popen_argv|popen_argv_pipe|popen_timed_out|run_and_capture|get_env_variable|set_env_variable|sanitize_command_line|has_env_variable)$%%),
224226
group_by_regex("Dynamic modules", mod, %regex~(register_dynamic_module|register_native_path)$%%)
225227
)
@@ -648,6 +650,15 @@ def document_module_ansi_colors(_root : string) {
648650
document("ANSI terminal color and style helpers", mod, "ansi_colors.rst", groups)
649651
}
650652

653+
def document_module_tty(_root : string) {
654+
var mod = find_module("tty")
655+
var groups <- array<DocGroup>(
656+
group_by_regex("Terminal detection", mod, %regex~.*(is_stream_terminal|is_stdin_terminal|is_stdout_terminal|is_stderr_terminal)$%%),
657+
group_by_regex("Terminal geometry", mod, %regex~.*(terminal_columns)$%%)
658+
)
659+
document("Terminal capability probes", mod, "tty.rst", groups)
660+
}
661+
651662
def document_module_algorithm(_root : string) {
652663
var mod = find_module("algorithm")
653664
var groups <- array<DocGroup>(
@@ -1839,6 +1850,7 @@ def main {
18391850
// document daslib modules
18401851
document_module_algorithm(root)
18411852
document_module_ansi_colors(root)
1853+
document_module_tty(root)
18421854
document_module_apply_in_context(root)
18431855
document_module_apply(root)
18441856
document_module_archive(root)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Returns true when the standard stream `fd` (0 for input, 1 for output, 2 for error) is attached to a terminal. Any other descriptor, and every stream that is a pipe or a file, returns false.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Returns the terminal width in columns, or 0 when nothing reports one — the stream is not a terminal, or the platform query failed. Falls back to the COLUMNS environment variable before giving up.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
The TTY module answers whether a stream is attached to a real terminal, and how
2+
wide that terminal is.
3+
4+
This is the gate for anything that redraws itself with a carriage return: a
5+
progress bar written to a pipe, a redirect, or a supervising process is garbage
6+
in the resulting log. Checking ``TERM`` is not a substitute — it stays set when
7+
standard output is redirected. ``daslib/ansi_colors`` handles styling; this
8+
module answers the capability question underneath it.
9+
10+
All functions and symbols are in "tty" module, use require to get access to it.
11+
12+
.. code-block:: das
13+
14+
require daslib/tty
15+
16+
Example:
17+
18+
.. code-block:: das
19+
20+
require daslib/tty
21+
22+
[export]
23+
def main() {
24+
if (is_stdout_terminal()) {
25+
print("drawing a {terminal_columns()}-column progress bar\n")
26+
} else {
27+
print("output is captured — emitting plain lines instead\n")
28+
}
29+
}

doc/source/stdlib/introduction.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ Developer tools
163163
---------------
164164

165165
* :doc:`ansi_colors <generated/ansi_colors>` — ANSI terminal color escape sequences
166+
* :doc:`tty <generated/tty>` — terminal capability probes (is it a terminal, how wide)
166167
* :doc:`command_line <generated/command_line>` — command-line argument parsing helpers
167168
* :doc:`das_source_formatter <generated/das_source_formatter>` — daslang source code formatter
168169
* :doc:`das_source_formatter_fio <generated/das_source_formatter_fio>` — file-based source code formatting

doc/source/stdlib/sec_io.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ File I/O, networking, URI parsing, terminal output, and binary serialization.
1717
generated/uriparser.rst
1818
generated/uriparser_boost.rst
1919
generated/ansi_colors.rst
20+
generated/tty.rst
2021
generated/archive.rst
2122
generated/command_line.rst
2223
generated/logger.rst

history/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,4 @@ listed in the log below — search it first when hunting for a doc.
9898
- 2026-07-25 `modules/dasLLAMA/benchmarks/prefill_metal_chase.das` -> `history/dasLLAMA/benchmarks/prefill_metal_chase.das` - Metal prefill chase instrument (arc shipped)
9999
- 2026-07-25 `modules/dasLLAMA/benchmarks/bench_metal_framing.das` -> `history/dasLLAMA/benchmarks/bench_metal_framing.das` - Metal framing-overhead bench (arc shipped)
100100
- 2026-07-25 `modules/dasLLAMA/benchmarks/gguf_perf.das` -> `history/dasLLAMA/benchmarks/gguf_perf.das` - gguf load-time bench (the .dlim rail made it moot)
101+
- 2026-07-27 `modules/dasLLVM/TUNE_PROGRESS_PLAN.md` -> `history/compiler/TUNE_PROGRESS_UX.md` - tune progress-display design (events + single renderer; arc shipped, operative content in skills/tune.md)

0 commit comments

Comments
 (0)