Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,11 @@ 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 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.
Expand Down
5 changes: 3 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 40 additions & 19 deletions crates/terminal-core/tests/pty_roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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);
Expand All @@ -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<String> = ["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");
Expand All @@ -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"] {
Expand All @@ -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")
});

Expand Down
140 changes: 132 additions & 8 deletions crates/tervin-app/tests/blocks_end_to_end.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -89,17 +103,25 @@ fn blocks_from(shell_program: &str, shell: Shell, commands: &[&str]) -> Vec<bloc
spill.path().to_path_buf(),
);

// Let the shell reach its first prompt before typing, so input is not
// swallowed by a shell still building its line editor.
std::thread::sleep(Duration::from_millis(1200));
let mut finished: Vec<block_engine::Block> = 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_until_reading(&session, &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<block_engine::Block> = Vec::new();
let deadline = Instant::now() + TIMEOUT;

while Instant::now() < deadline && finished.len() < commands.len() {
Expand Down Expand Up @@ -128,6 +150,108 @@ fn blocks_from(shell_program: &str, shell: Shell, commands: &[&str]) -> Vec<bloc
finished
}

/// Wait until the shell is not merely showing a prompt but reading from the pty.
///
/// Those are different states, and the gap between them is where the first
/// character of the first command went. `133;B` lives inside `PS1`, so it is
/// emitted when the prompt is *printed*. zsh's line editor then calls `tcsetattr`
/// with a flush, which discards anything typed in the meantime. On an idle machine
/// that window is too narrow to hit. Under a full `cargo test --workspace` it is
/// wide enough to swallow an `e` — which is why CI failed on `cho tervin-block-one`
/// while running this crate on its own never did, and why waiting for the prompt
/// marker alone was not enough.
///
/// So this asks the shell to prove it, rather than inferring readiness from output:
/// send a newline and wait for the prompt drawn in reply. A prompt that appears
/// *after* input was sent is one the shell could only have drawn by reading that
/// input. An empty line runs no command, so it forms no Block and the counts the
/// tests assert are unaffected. If the newline is swallowed too, that is simply a
/// shell that was not ready, and asking again is the right response.
fn wait_until_reading(
session: &terminal_core::PtySession,
rx: &mpsc::Receiver<PtyEvent>,
builder: &mut BlockBuilder,
finished: &mut Vec<block_engine::Block>,
) -> 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,
/// and losing that bet is not merely slow. zsh's line editor calls `tcsetattr` with
/// a flush when it initialises, which discards whatever has already been typed, so
/// a sleep that is fractionally too short does not delay the input — it eats the
/// front of it. The Linux runner produced `cho` where this test typed `echo`, and
/// reported it as a command that "did not survive the round trip", which points at
/// the marker pipeline rather than at the clock.
///
/// `PromptEnd` is OSC 133;B, which zsh's injected integration emits once the prompt
/// is drawn and the shell is ready to read. Waiting for it is exact: measured here
/// at 485ms for the first prompt and 17ms for later ones, against a 1200ms guess.
///
/// Bash never sends it in this configuration. It is a login shell, and the marker
/// is appended to `PS1` by the injected rc file, so anything that sets `PS1`
/// afterwards drops it. Its `133;A` still arrives, so that plus a short settle is
/// what bash gets. Worth knowing beyond this test: any feature keyed on `PromptEnd`
/// is not getting one from bash.
fn wait_for_prompt(
rx: &mpsc::Receiver<PtyEvent>,
builder: &mut BlockBuilder,
finished: &mut Vec<block_engine::Block>,
) -> bool {
let deadline = Instant::now() + PROMPT_WAIT;
let mut prompt_started: Option<Instant> = 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<PtyEvent>) -> terminal_core::PtySession {
terminal_core::PtySession::spawn(
Expand Down
Loading
Loading