From eddb1681fa1f91b691505d473bfe896e549ec06e Mon Sep 17 00:00:00 2001 From: QuintinBotes Date: Mon, 3 Aug 2026 00:32:25 +0200 Subject: [PATCH 1/6] Run the suite on Linux, to find out rather than assume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §3.1 says the honest blocker for Linux is not that the code is macOS-specific, it is that nothing has been run there. So this runs it. The job installs the webview and GTK libraries Tauri links against, because tervin-app is in the workspace and even `cargo test` needs them, plus zsh and vim, because the terminal tests drive real ones and skip silently when they are absent. A green run that skipped the tests that matter would be worse than a red one. Clippy runs here as well as on macOS: `cfg` differences mean each platform compiles code the other does not, so a lint can be clean on one and not the other. The README's platform claim does not change. A green CI run proves the tests pass on Linux, not that the product is usable there, and §3.1's exit criterion is a person having actually used it for a day. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc9e37e..7298f67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,46 @@ jobs: command -v "$tool" >/dev/null || { echo "::error::$tool is missing, so its tests were skipped"; exit 1; } done + linux: + # Tervin has only ever been run on macOS. The code is written for Unix generally + # and nothing in the PTY layer is macOS-specific, but "should work" is not a + # claim worth making, so this job exists to find out. README still says macOS is + # the tested platform, and that does not change until a person has used Linux for + # a day: a green CI run proves the tests pass, not that the product is usable. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: System dependencies + # Tauri links against the system webview, so even `cargo test` needs these: + # tervin-app is part of the workspace. `zsh` and `vim` are here because the + # terminal tests drive them and skip silently when they are missing, which + # would make this job green while proving very little. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev patchelf zsh vim + + - name: Confirm the tools the terminal tests need are present + # Before the tests rather than after, so a missing tool reads as setup being + # wrong rather than as a test failure. + run: | + for tool in vim less zsh; do + command -v "$tool" >/dev/null || { echo "::error::$tool is missing, so its tests would be skipped"; exit 1; } + done + + - name: Tests + run: cargo test --workspace + + - name: Clippy + # Separate from the macOS job because `cfg` differences mean each platform + # compiles code the other does not, so a lint can be clean on one and not the + # other. + run: cargo clippy --workspace --all-targets -- -D warnings + ui: runs-on: ubuntu-latest steps: From 9b1bac7995516a97667de8f03bcbfa71374ba86b Mon Sep 17 00:00:00 2001 From: Quintin Botes Date: Mon, 3 Aug 2026 00:45:33 +0200 Subject: [PATCH 2/6] Apply the macOS-and-Linux-only decision to the docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows is not in the roadmap: ConPTY covers the PTY layer but shell integration has no equivalent to ZDOTDIR, and Tervin's design assumes a Unix shell throughout. macOS and Linux is a coherent scope; Windows is not. Moved to §5 with reasoning. Six locations updated: - §1 baseline: no Linux build (in CI; not yet claimed), no Windows - §2 Warp gap table: Linux/close it, Windows/deliberately not - §3.1: renamed to 'Linux build', removed Windows content, exit criterion is 'both' not 'all three' - §5: Windows added with rationale - §6 ordered plan: Linux in CI (not Linux and Windows) - README.md and SECURITY.md: Linux not yet claimed, Windows not a target Co-Authored-By: Claude Sonnet 4.6 --- README.md | 4 +++- SECURITY.md | 5 +++-- docs/COMPETITIVE-SPEC.md | 23 ++++++++++++++--------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5059678..0f0be9b 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,9 @@ What is deliberately incomplete, and tracked rather than hidden: user's shell all have real costs, and picking wrong is worse than waiting. - **SSH latency and reconnect indicators.** SSH exposes no round-trip time, so a number here would be a measurement of something else wearing a latency label. -- **Linux and Windows.** Not claimed, because not exercised. +- **Linux.** Not yet claimed. CI now runs the suite there; the platform claim changes only after + a person has actually used it for a day. +- **Windows.** Not a target platform. The commit history is the honest record: several commits exist because a test caught the implementation, and a few because a test was itself wrong. Both are labelled as such. diff --git a/SECURITY.md b/SECURITY.md index 2823456..925b8e3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -180,8 +180,9 @@ right-click, Open, once, or switch to a route that never triggers it. Stated rather than omitted: -- **Linux is untested.** The code is written for Unix generally, but untested is not - supported. +- **Linux is not yet claimed as supported.** The code is written for Unix generally; CI now + runs the suite on Linux, but CI green is not the same as supported. The platform claim + changes only after a person has actually used it for a day. - **A malicious agent is not contained.** Tervin gates what it is asked about. An agent that finds a path Tervin does not mediate, through a command that spawns another process, for instance, is limited by your OS, not by Tervin. Use the OS mechanisms diff --git a/docs/COMPETITIVE-SPEC.md b/docs/COMPETITIVE-SPEC.md index b256fdd..815234e 100644 --- a/docs/COMPETITIVE-SPEC.md +++ b/docs/COMPETITIVE-SPEC.md @@ -36,7 +36,7 @@ the transcript they already write. Commands an agent runs become Blocks. reason on every absence. `exit_code_reported` so a derived exit status is never shown as a measured one. Tervin never answers `allow` on a runtime's behalf. -**Not present, and worth saying:** no Linux or Windows build, no signing (§5, a decision +**Not present, and worth saying:** no Linux build (in CI; not yet claimed), no signing (§5, a decision rather than a gap), no kitty keyboard or graphics protocol, no scripting API, no plugin system, no team or sync features, no instant replay, no broadcast input, no vi-mode scrollback, no tmux control mode, no CLI flag @@ -57,7 +57,7 @@ specific matters more than being reassuring. | --- | --- | --- | | **Cloud agents (Oz)** | Event-triggered autonomous agents in containers, reacting to webhooks, CI, cron and Slack. 20 to 40 concurrent depending on tier. | **Close it, differently.** See §4. | | **Warp Drive** | Team-synced storage of workflows, notebooks, environment profiles and MCP server lists. | **Close the local half, refuse the cloud half.** See §4.4. | -| **Cross-platform** | GA on macOS, Linux (X11 and Wayland) and Windows, including ARM64. | **Close it.** §3.1. | +| **Cross-platform** | GA on macOS, Linux (X11 and Wayland) and Windows, including ARM64. | **Linux: close it.** §3.1. **Windows: deliberately not.** §5. | | **CLI flag completion** | Subcommand and flag completion for hundreds of CLIs, no plugin needed. | **Close it.** §3.2. | | **Blocks over SSH** | Shell integration, blocks and AI survive an SSH hop and subshells (`nvm`, venv, `docker exec`, `kubectl exec`). | **Close it.** §3.3. | | **Notebook blocks** | A command, its output and prose, shared as a link with execution context. | Partly. §4.4. | @@ -222,14 +222,13 @@ it. **§4.2: small work, real interoperability.** Ordered by value per unit of work, not by section number. -### 3.1 Linux and Windows builds +### 3.1 Linux build `P1.` The code is Unix-general already and the PTY layer has no macOS-specific assumptions; -the honest blocker is that nothing has been *run* there. Add both to CI first, fix what -breaks, and only then claim support. Windows needs ConPTY behind the `portable-pty` -abstraction and a decision about shell integration, which has no equivalent to `ZDOTDIR`. +the honest blocker is that nothing has been *run* there. Add Linux to CI first, fix what +breaks, and only then claim support. -*Exit criteria:* CI green on all three, and the README's platform claim changes only after a -human has actually used each for a day. +*Exit criteria:* CI green on both, and the README's platform claim changes only after a +human has actually used Linux for a day. ### 3.2 CLI flag and subcommand completion `P1.` The largest remaining Warp gap. Three approaches, and this specification picks one: @@ -537,13 +536,19 @@ Naming these matters as much as the roadmap, because each is a plausible request about covers the realistic threat, a substituted download. It does not cover a verified third-party identity, which is a real thing to be missing and is said plainly in `SECURITY.md` rather than glossed. +- **Windows.** ConPTY covers the PTY layer, but shell integration has no equivalent to + `ZDOTDIR`, and the approach for automatic injection (§3.1) does not carry over. Tervin's + design assumes a Unix shell throughout: every agent runtime, every shell-integration hook, + and the completion design (§3.2) all depend on it. Solving that cleanly requires a design + that does not exist yet, and macOS plus Linux is a coherent scope. Windows is not in the + roadmap. - **Instant replay**, probably. Blocks answer the question people actually ask. --- ## 6. Ordered plan -**Now: credibility.** Linux and Windows in CI (§3.1). Read `AGENTS.md` and existing MCP +**Now: credibility.** Linux in CI (§3.1). Read `AGENTS.md` and existing MCP config (§4.2). CLI completion via the shell (§3.2). **Next: the two structural gaps.** Detach and reattach (§3.8). Parallel Threads with worktree From bc12832c32042a37dd123a00c9bb1cd3209338f8 Mon Sep 17 00:00:00 2001 From: QuintinBotes Date: Mon, 3 Aug 2026 01:03:58 +0200 Subject: [PATCH 3/6] Record Windows as deferred rather than refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5 is for things Tervin will not build. Windows is not one of them: nothing in the design forecloses it, and the cost is a shell-integration story nobody has written rather than a decision against it. ConPTY already covers the PTY layer behind portable-pty; the blocker is that there is no ZDOTDIR equivalent, so the automatic injection in §3.1 does not carry over and §3.2's completion design assumes a Unix shell. So it moves to §6 "Later, or never" with that reasoning, and the comparison table points at §6 instead of §5. The README says "not a target for now" and names the blocker rather than implying the door is shut. Linux stays unclaimed. CI running the suite is not the same as somebody having used it, and §3.1's own exit criterion is a day of real use. Co-Authored-By: Claude Opus 5 --- README.md | 4 +++- docs/COMPETITIVE-SPEC.md | 16 +++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0f0be9b..170a11e 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,9 @@ What is deliberately incomplete, and tracked rather than hidden: here would be a measurement of something else wearing a latency label. - **Linux.** Not yet claimed. CI now runs the suite there; the platform claim changes only after a person has actually used it for a day. -- **Windows.** Not a target platform. +- **Windows.** Not a target for now. Deferred rather than refused: the blocker is shell + integration, which has no `ZDOTDIR` equivalent, not the terminal itself. Reasoning in + [COMPETITIVE-SPEC.md](docs/COMPETITIVE-SPEC.md) §6. The commit history is the honest record: several commits exist because a test caught the implementation, and a few because a test was itself wrong. Both are labelled as such. diff --git a/docs/COMPETITIVE-SPEC.md b/docs/COMPETITIVE-SPEC.md index 815234e..6f2ac78 100644 --- a/docs/COMPETITIVE-SPEC.md +++ b/docs/COMPETITIVE-SPEC.md @@ -57,7 +57,7 @@ specific matters more than being reassuring. | --- | --- | --- | | **Cloud agents (Oz)** | Event-triggered autonomous agents in containers, reacting to webhooks, CI, cron and Slack. 20 to 40 concurrent depending on tier. | **Close it, differently.** See §4. | | **Warp Drive** | Team-synced storage of workflows, notebooks, environment profiles and MCP server lists. | **Close the local half, refuse the cloud half.** See §4.4. | -| **Cross-platform** | GA on macOS, Linux (X11 and Wayland) and Windows, including ARM64. | **Linux: close it.** §3.1. **Windows: deliberately not.** §5. | +| **Cross-platform** | GA on macOS, Linux (X11 and Wayland) and Windows, including ARM64. | **Linux: close it.** §3.1. **Windows: deferred.** §6. | | **CLI flag completion** | Subcommand and flag completion for hundreds of CLIs, no plugin needed. | **Close it.** §3.2. | | **Blocks over SSH** | Shell integration, blocks and AI survive an SSH hop and subshells (`nvm`, venv, `docker exec`, `kubectl exec`). | **Close it.** §3.3. | | **Notebook blocks** | A command, its output and prose, shared as a link with execution context. | Partly. §4.4. | @@ -536,12 +536,6 @@ Naming these matters as much as the roadmap, because each is a plausible request about covers the realistic threat, a substituted download. It does not cover a verified third-party identity, which is a real thing to be missing and is said plainly in `SECURITY.md` rather than glossed. -- **Windows.** ConPTY covers the PTY layer, but shell integration has no equivalent to - `ZDOTDIR`, and the approach for automatic injection (§3.1) does not carry over. Tervin's - design assumes a Unix shell throughout: every agent runtime, every shell-integration hook, - and the completion design (§3.2) all depend on it. Solving that cleanly requires a design - that does not exist yet, and macOS plus Linux is a coherent scope. Windows is not in the - roadmap. - **Instant replay**, probably. Blocks answer the question people actually ask. --- @@ -565,6 +559,14 @@ a cloud agent's PR being the part nobody else has (§4.3). Local models as real **Later, or never.** Floating panes, layout artefacts, history sync, the MCP server, instant replay. Each is defensible; none changes the argument for using Tervin. +**Windows: deferred, not refused.** ConPTY covers the PTY layer behind `portable-pty`, so the +terminal itself is tractable. Shell integration is the blocker: there is no equivalent to +`ZDOTDIR`, so the automatic injection in §3.1 does not carry over, and the completion design +in §3.2 assumes a Unix shell. Tervin's scope is macOS and Linux, which is coherent because +both share those assumptions. This is in the roadmap rather than in §5 deliberately: nothing +about the design forecloses Windows, and the cost is a shell-integration story nobody has +written yet rather than a decision against it. + --- ## Sources From 019bc9ea01a9e25a87a4f127c8b163d28f4559c4 Mon Sep 17 00:00:00 2001 From: Quintin Botes Date: Mon, 3 Aug 2026 02:24:55 +0200 Subject: [PATCH 4/6] Stop the PTY tests passing on their own echo Linux CI failed on `a_large_burst_of_output_arrives_intact` with "line-2500 missing from a 5000-line burst". It reads as the coalescing pump dropping a chunk under load. It is not: the pump never drops anything, and the test never got as far as the burst. A PTY echoes what is typed. The test typed a command ending in `echo BURST-DONE` and then collected until the text contained `BURST-DONE`, which the echo of that very command satisfies. Collection stopped 81 bytes in, holding nothing but the echoed input line, and the missing output was then reported as lost data. Reproduced in a Linux container and confirmed by dumping the raw stream, which was exactly: "# i=0; while [ $i -lt 5000 ]; do echo line-$i; i=$((i+1)); done; echo BURST-DONE\r\n" Every marker in this file was written the same way, so this was never specific to the burst. `a_real_shell_receives_input_and_returns_output` waited for `tervin-roundtrip-ok` after typing it, and its comment claims it proves output comes back from a real shell. It did not: the echo alone satisfied it, and the assertion after it read the input back. Same for the ordering test and the shell-integration markers test, which also failed on Linux for this reason rather than for two separate ones. The markers are now typed split by an empty quote, so the echo reads `B''URST-DONE` while the shell still prints `BURST-DONE` and only real output can match. That keeps the sentinel readable at both ends, which a random nonce would not. macOS passed throughout, which is the whole argument for running this suite on Linux: the shells differ, the timing differs, and a test that had never once exercised the thing it names went unnoticed for as long as one platform was generous about it. No production code changes. terminal-core 7 of 7 pass on both platforms. Co-Authored-By: Claude Opus 5 --- crates/terminal-core/tests/pty_roundtrip.rs | 59 ++++++++++++++------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/crates/terminal-core/tests/pty_roundtrip.rs b/crates/terminal-core/tests/pty_roundtrip.rs index 68529a1..7f6f645 100644 --- a/crates/terminal-core/tests/pty_roundtrip.rs +++ b/crates/terminal-core/tests/pty_roundtrip.rs @@ -87,6 +87,22 @@ fn run(program: &str, args: &[&str], input: &[&str], done: impl Fn(&str) -> bool collected } +/// Write a word so the terminal's echo of it cannot pass for the shell's output. +/// +/// A PTY echoes what is typed. A test that types `echo FOO` and then waits for +/// `FOO` is therefore satisfied by its own echo, before the shell has run at all, +/// and every assertion after it is reading the input back. Splitting the word with +/// an empty quote leaves the echo reading `F''OO` while the shell still prints +/// `FOO`, so only real output can match. +/// +/// This is not hypothetical. Every marker in this file was written the plain way, +/// which is why the burst test stopped collecting after 81 bytes and then reported +/// the missing lines as though the pump had dropped them. +fn only_in_output(word: &str) -> String { + let (head, tail) = word.split_at(1); + format!("{head}''{tail}") +} + /// Strip escape sequences so assertions match what a user would read. fn plain(text: &str) -> String { let mut out = String::with_capacity(text.len()); @@ -135,7 +151,8 @@ fn a_real_shell_receives_input_and_returns_output() { // The input path: keystrokes written to the PTY must reach the shell and its // output must come back through the pump. A screenshot of a prompt proves // only the output half. - let collected = run("/bin/sh", &[], &["echo tervin-roundtrip-ok\n"], |text| { + let line = format!("echo {}\n", only_in_output("tervin-roundtrip-ok")); + let collected = run("/bin/sh", &[], &[&line], |text| { text.contains("tervin-roundtrip-ok") }); let text = plain(&collected.text); @@ -148,12 +165,12 @@ fn a_real_shell_receives_input_and_returns_output() { #[test] fn output_arrives_in_order_across_several_commands() { // The coalescer batches reads; batching must never reorder them. - let collected = run( - "/bin/sh", - &[], - &["echo one\n", "echo two\n", "echo three\n"], - |text| text.contains("three"), - ); + let lines: Vec = ["one", "two", "three"] + .iter() + .map(|w| format!("echo {}\n", only_in_output(w))) + .collect(); + let refs: Vec<&str> = lines.iter().map(String::as_str).collect(); + let collected = run("/bin/sh", &[], &refs, |text| text.contains("three")); let text = plain(&collected.text); let one = text.find("one"); let two = text.find("two"); @@ -172,12 +189,13 @@ fn output_arrives_in_order_across_several_commands() { fn a_large_burst_of_output_arrives_intact() { // The pump flushes early at a size threshold; nothing may be dropped at the // boundary. 5000 lines crosses it many times over. - let collected = run( - "/bin/sh", - &[], - &["i=0; while [ $i -lt 5000 ]; do echo line-$i; i=$((i+1)); done; echo BURST-DONE\n"], - |text| text.contains("BURST-DONE"), + let script = format!( + "i=0; while [ $i -lt 5000 ]; do echo line-$i; i=$((i+1)); done; echo {}\n", + only_in_output("BURST-DONE"), ); + let collected = run("/bin/sh", &[], &[&script], |text| { + text.contains("BURST-DONE") + }); let text = plain(&collected.text); assert!(text.contains("BURST-DONE"), "burst never completed"); for probe in ["line-0", "line-2500", "line-4999"] { @@ -192,14 +210,17 @@ fn a_large_burst_of_output_arrives_intact() { fn shell_integration_markers_survive_the_round_trip() { // Emitted by the shell, extracted by the tap, delivered on the chunk. If this // breaks, Blocks silently stop forming. - let script = concat!( - r#"printf '\033]7373;cmd=ZWNobyBoaQ==\007';"#, - r#"printf '\033]133;C\007';"#, - "echo hi;", - r#"printf '\033]133;D;0\007';"#, - "echo MARKERS-DONE\n", + let script = format!( + concat!( + r#"printf '\033]7373;cmd=ZWNobyBoaQ==\007';"#, + r#"printf '\033]133;C\007';"#, + "echo hi;", + r#"printf '\033]133;D;0\007';"#, + "echo {}\n", + ), + only_in_output("MARKERS-DONE"), ); - let collected = run("/bin/sh", &[], &[script], |text| { + let collected = run("/bin/sh", &[], &[&script], |text| { text.contains("MARKERS-DONE") }); From bf094650959b3ce18e8fdad2602f8cd014d8c1f2 Mon Sep 17 00:00:00 2001 From: Quintin Botes Date: Mon, 3 Aug 2026 02:55:26 +0200 Subject: [PATCH 5/6] Wait for the shell's prompt instead of guessing how long it takes With the PTY tests fixed, the Linux job got as far as the Blocks end-to-end suite and failed six of its eight tests. The clearest one: left: "cho tervin-block-one" right: "echo tervin-block-one" The leading `e` was gone, and the assertion called it a command that "did not survive the round trip", which points at the marker pipeline. The pipeline was fine. The test slept a fixed 1200ms before typing, and zsh's line editor calls `tcsetattr` with a flush when it initialises, which discards whatever has already been typed. On a slower machine that sleep does not merely wait too little; it eats the front of the first command. So the sleeps are gone and the test waits for `133;B`, which the injected integration emits once the prompt is drawn and the shell is reading. Measured on zsh: 485ms for the first prompt and 17ms for each one after, against a guess of 1200ms that was both too slow and not slow enough. Bash does not send `133;B` here, which is worth recording beyond this file. It is a login shell and the marker is appended to `PS1` by the injected rc, so anything setting `PS1` afterwards drops it. Verified rather than assumed: bash waited out both waits while zsh answered in milliseconds. Blocks still form for bash, because the other 133 markers arrive, but a feature keyed on `PromptEnd` is getting nothing from that shell. A shell that reports no prompt is therefore asked once and then left alone, falling back to the spacing this file used before. Asking again only buys another wait for an answer that is not coming, and doing that per command is what turned a 32-second file into a 90-second one on the first attempt. The macOS bash test was already 31.9s before any of this: bash 3.2 forms no Blocks, which the test explicitly allows, so the collection loop waits out its deadline. That is unchanged and not introduced here. The file goes 31.9s to 35.5s on macOS, and the zsh tests it exists for are now exact rather than timed. 8 of 8 pass on macOS, and on Linux under zsh 5.9 and bash 5.2.37. Co-Authored-By: Claude Opus 5 --- crates/tervin-app/tests/blocks_end_to_end.rs | 102 +++++++++++++++++-- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/crates/tervin-app/tests/blocks_end_to_end.rs b/crates/tervin-app/tests/blocks_end_to_end.rs index d2ef605..67bf1cb 100644 --- a/crates/tervin-app/tests/blocks_end_to_end.rs +++ b/crates/tervin-app/tests/blocks_end_to_end.rs @@ -15,13 +15,27 @@ use shell_integration::{InjectionMode, Shell}; use std::sync::mpsc; use std::sync::Arc; use std::time::{Duration, Instant}; -use terminal_core::{PtyConfig, PtyEvent}; +use terminal_core::{PtyConfig, PtyEvent, ShellSignal}; use tervin_core::{PaneId, SessionId}; /// Generous, because a login shell sources the user's rc files — which on a real /// machine can mean a version manager and a completion framework. const TIMEOUT: Duration = Duration::from_secs(30); +/// How long after `133;A` to treat a shell that sends no `133;B` as ready. +const PROMPT_SETTLE: Duration = Duration::from_millis(300); + +/// Longest to wait for a prompt before deciding this shell does not report them. +/// +/// Bounded separately from [`TIMEOUT`] because it is a different question. Waiting +/// the full timeout for a signal that is never coming is what turned this file from +/// a 32-second run into a 90-second one. +const PROMPT_WAIT: Duration = Duration::from_secs(5); + +/// Fallback spacing for a shell that reports no prompts, as this file used +/// throughout before it learned to wait for one. +const BLIND_SETTLE: Duration = Duration::from_millis(400); + /// A scratch directory that cleans itself up. struct Scratch(std::path::PathBuf); @@ -89,17 +103,25 @@ fn blocks_from(shell_program: &str, shell: Shell, commands: &[&str]) -> Vec = Vec::new(); + + // Wait for the shell to say it is ready, rather than betting on how long that + // takes. One command at a time, each typed into a drawn prompt, so each forms + // its own Block. + // + // A shell that does not report prompts at all — bash 3.2, which macOS still + // ships — is asked once and then left alone, because asking again only buys + // another wait for an answer that is not coming. + let mut reports_prompts = wait_for_prompt(&rx, &mut builder, &mut finished); for command in commands { session.write(command.as_bytes()).expect("write failed"); - // One at a time, so each produces its own Block rather than being typed - // into a shell that has not yet drawn a new prompt. - std::thread::sleep(Duration::from_millis(400)); + if reports_prompts { + reports_prompts = wait_for_prompt(&rx, &mut builder, &mut finished); + } else { + std::thread::sleep(BLIND_SETTLE); + } } - let mut finished: Vec = Vec::new(); let deadline = Instant::now() + TIMEOUT; while Instant::now() < deadline && finished.len() < commands.len() { @@ -128,6 +150,70 @@ fn blocks_from(shell_program: &str, shell: Shell, commands: &[&str]) -> Vec, + builder: &mut BlockBuilder, + finished: &mut Vec, +) -> bool { + let deadline = Instant::now() + PROMPT_WAIT; + let mut prompt_started: Option = None; + + while Instant::now() < deadline { + match rx.recv_timeout(Duration::from_millis(50)) { + Ok(PtyEvent::Chunk(chunk)) => { + let mut ready = false; + for positioned in &chunk.signals { + match positioned.signal { + // Definitive: the prompt is drawn and the shell is reading. + ShellSignal::PromptEnd => ready = true, + ShellSignal::PromptStart => { + prompt_started.get_or_insert_with(Instant::now); + } + _ => {} + } + } + for event in builder.consume(&chunk) { + if let BlockEvent::Finished(block) = event { + finished.push(block); + } + } + if ready { + return true; + } + } + Ok(PtyEvent::Exited { .. }) => return false, + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => return false, + } + + // A shell that announced a prompt but sends no `133;B` still told us + // something. Take it, once it has had a moment to finish drawing. + if prompt_started.is_some_and(|t| t.elapsed() >= PROMPT_SETTLE) { + return true; + } + } + false +} + /// Small helper so the sink closure stays readable at the call site. fn spawn_session(config: PtyConfig, tx: mpsc::Sender) -> terminal_core::PtySession { terminal_core::PtySession::spawn( From 34fd761e6b659e489713e6cee8393f16594cf314 Mon Sep 17 00:00:00 2001 From: Quintin Botes Date: Mon, 3 Aug 2026 03:21:05 +0200 Subject: [PATCH 6/6] Make the shell prove it is reading before typing into it Waiting for `133;B` was not enough, and CI said so with the same `cho` where the test typed `echo`. The marker lives inside `PS1`, so it is emitted when the prompt is *printed*. zsh's line editor then calls `tcsetattr` with a flush, which discards whatever arrived in between. A printed prompt and a reading shell are different states, and the first character of the first command was landing in the gap between them. That gap is a function of load, which is why this reproduced on CI running `cargo test --workspace` and never once when this crate was run on its own. So readiness is no longer inferred from output. The test sends a newline and waits for the prompt drawn in reply: a prompt that appears *after* input was sent is one the shell could only have drawn by having read it. If that newline is swallowed too, the shell was not ready and it asks again, up to five times. An empty line runs no command, so no Block forms and the exact block counts these tests assert are unchanged. The point of the change is that it no longer depends on timing at all. The previous two attempts both replaced one duration with a better duration, which is the same bet at longer odds. 8 of 8 on macOS, and on Linux under `cargo test --workspace`, which is the load that produced the failure. Verified in a container before pushing rather than after, which is what the previous two attempts got wrong. Co-Authored-By: Claude Opus 5 --- crates/tervin-app/tests/blocks_end_to_end.rs | 40 +++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/tervin-app/tests/blocks_end_to_end.rs b/crates/tervin-app/tests/blocks_end_to_end.rs index 67bf1cb..3329ffd 100644 --- a/crates/tervin-app/tests/blocks_end_to_end.rs +++ b/crates/tervin-app/tests/blocks_end_to_end.rs @@ -112,7 +112,7 @@ fn blocks_from(shell_program: &str, shell: Shell, commands: &[&str]) -> Vec Vec, + builder: &mut BlockBuilder, + finished: &mut Vec, +) -> bool { + // A shell that never reports a prompt cannot be asked to prove anything. + if !wait_for_prompt(rx, builder, finished) { + return false; + } + for _ in 0..5 { + if session.write(b"\n").is_err() { + return false; + } + if wait_for_prompt(rx, builder, finished) { + return true; + } + } + false +} + /// Drain events until the shell has drawn a prompt, keeping any Blocks that finish. /// /// A fixed sleep here is a bet on how fast someone else's machine starts a shell,