From b44ff8e773f6f7d02ac90bec663a0d1c2e109068 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:37:26 -0400 Subject: [PATCH] Improve release and benchmark tooling --- .github/workflows/ci.yml | 27 ++++ Makefile | 5 +- README.md | 18 ++- docs/BENCHMARKS.md | 4 +- docs/PUBLIC_READINESS.md | 4 +- install.ps1 | 213 +++++++++++++++++++++++++------- install.sh | 31 +++++ scripts/bench-report.sh | 21 ++++ scripts/package-release.sh | 39 +++++- scripts/upload-gitea-release.sh | 71 +++++++++++ src/bin/dosh-bench.rs | 150 ++++++++++++++++++++-- src/bin/dosh-client.rs | 98 ++++++++++++++- 12 files changed, 608 insertions(+), 73 deletions(-) create mode 100755 scripts/bench-report.sh create mode 100755 scripts/upload-gitea-release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae92c07..f0d1c4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,33 @@ jobs: if: steps.nightly.outcome != 'success' || steps.install.outcome != 'success' run: echo "cargo-fuzz / nightly toolchain unavailable; skipped fuzz smoke run." + package-release: + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') + strategy: + matrix: + include: + - os: ubuntu-latest + name: linux-x86_64 + - os: macos-14 + name: macos-aarch64 + - os: macos-13 + name: macos-x86_64 + - os: windows-latest + name: windows-x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Package release + shell: bash + run: sh scripts/package-release.sh + - uses: actions/upload-artifact@v4 + with: + name: dosh-${{ matrix.name }} + path: | + target/dosh-release/dosh-* + !target/dosh-release/stage/** + remote-bench: runs-on: ubuntu-latest env: diff --git a/Makefile b/Makefile index ac7c30f..6fae638 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test fmt install package-release bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local +.PHONY: build test fmt install package-release bench-report bench-local bench-local-json bench-docker-ssh bench-docker-mosh fuzz-smoke fuzz-deep soak-local build: cargo build --release @@ -15,6 +15,9 @@ install: package-release: sh scripts/package-release.sh +bench-report: + sh scripts/bench-report.sh + # Safe, self-contained local benchmark matrix (native cold auth, cached attach # ticket, local-auth) on a throwaway server bound to 127.0.0.1 on a free port in # a temp HOME. Never touches the production server or UDP port 50000. diff --git a/README.md b/README.md index c288785..cf78c53 100644 --- a/README.md +++ b/README.md @@ -117,12 +117,15 @@ Update an installed client later: ```bash dosh update +dosh update --check +dosh --version ``` `dosh update` first tries a release tarball named for the platform (`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest Gitea/GitHub release. If that asset is not published yet, it falls back to the -source build path. +source build path. If the release also publishes `.sha256`, the installer +verifies the archive before installing it. Install the client on Windows PowerShell: @@ -247,6 +250,15 @@ fast path after the first authentication has issued an attach ticket. See strongest claim is fast attach/reconnect plus native encrypted forwarding on Dosh-installed servers, not generic SSH compatibility. +Generate a publishable Markdown benchmark report: + +```bash +make bench-report +``` + +Set `DOSH_BENCH_SERVER`, `DOSH_BENCH_ITERS`, `DOSH_BENCH_ARGS`, and +`DOSH_BENCH_REPORT` to target a real host and choose the output path. + Run the explicit pre-launch soak and fuzz gates: ```bash @@ -275,6 +287,7 @@ Build release tarballs for upload to Gitea/GitHub releases: ```bash make package-release +GITEA_TOKEN=... scripts/upload-gitea-release.sh v0.1.0 target/dosh-release/dosh-* ``` ## Performance Rules @@ -311,7 +324,8 @@ Hot-path rules: - Replacing SSH as the first public-key trust mechanism. - Multi-user access control. -- Windows support in v0. +- Windows server/full parity in v0; the client installer supports prebuilt Windows + client artifacts when published. - Full mosh compatibility. - Perfect predictive local echo in the first MVP. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index a6a1531..2fa4c16 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -17,6 +17,7 @@ make bench-local # safe, self-contained matrix on a throwaway server make bench-local-json # same, machine-readable JSON with raw samples make bench-docker-ssh # containerized SSH-vs-Dosh gate used by CI make bench-docker-mosh # same, with Mosh installed for a three-way comparison +make bench-report # Markdown report wrapper around dosh-bench ``` `make bench-local` never touches a running production server: it builds release @@ -35,7 +36,8 @@ tool pays before any useful remote work begins. Every run prints, per metric, a summary line (count, min, median, p95, mean, max in milliseconds) **and** a raw-samples line. Pass `--json` for one machine-readable object per run (with the full `samples_ms` array) so published numbers can always -include raw data, as required by `docs/PUBLIC_READINESS.md`. +include raw data, as required by `docs/PUBLIC_READINESS.md`. Pass `--markdown` for a +publishable Markdown table, and `--output path/to/report.md` to write it to disk. ### Path matrix diff --git a/docs/PUBLIC_READINESS.md b/docs/PUBLIC_READINESS.md index c402803..3fadb2f 100644 --- a/docs/PUBLIC_READINESS.md +++ b/docs/PUBLIC_READINESS.md @@ -187,7 +187,7 @@ from code; in progress = partially implemented; pending = not yet implemented. | `-f -N -L` backgrounds only after listener readiness | done | `spawn_background_forwarder` waits for a readiness token. | | Multiple forwards in one command | done | Forward lists parsed and started together. | | `dosh doctor` identifies UDP-blocked/auth-denied/mismatch/forwarding-denied | done | `run_doctor_command` reports each state. | -| Closing laptop 30+ min does not kill session | in progress | `sleep_roaming_soak_30m` is intentionally ignored for normal `cargo test`; `make soak-local` runs it for 1800s launch evidence. | +| Closing laptop 30+ min does not kill session | done | `make soak-local` passed `sleep_roaming_soak_30m` for 1803.54s locally. Keep rerunning before tagged releases. | | Three concurrent terminals independent unless named | done | Generated session names per attach; named sessions shared on purpose. | | Large forwarded transfers add no visible input lag | done | Covered by the local-forward bulk-load integration test; still needs real-host soak before launch claims. | | Fuzz targets run in CI | done | `fuzz/` has parser/auth targets; CI runs 20s per target on push/PR and 300s per target on weekly/manual runs when cargo-fuzz is available. | @@ -206,7 +206,7 @@ Additional security hardening tracked outside the section 16 list: ## Before Public Launch - Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green. -- Run `make soak-local` before launch to produce 30-minute sleep/roaming evidence. +- Run `make soak-local` before each tagged release to refresh 30-minute sleep/roaming evidence. - Run `make fuzz-deep` before launch, or use the scheduled/manual CI fuzz pass, and publish the target durations. - Keep scripted TUI tests green and add a broader app matrix for real alternate-screen diff --git a/install.ps1 b/install.ps1 index adc1006..dc4cdce 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,6 +6,12 @@ param( [string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }), [int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }), [string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }), + [switch]$UsePrebuilt = $($env:DOSH_USE_PREBUILT -and $env:DOSH_USE_PREBUILT -ne "0"), + [string]$BinaryUrl = $env:DOSH_BINARY_URL, + [string]$BinaryBase = $env:DOSH_BINARY_BASE, + [string]$BinaryName = $env:DOSH_BINARY_NAME, + [string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }), + [switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"), [switch]$ForceConfig ) @@ -17,39 +23,157 @@ function Require-Command($Name) { } } -Require-Command cargo - -$tmp = $null -if (Test-Path "Cargo.toml") { - $src = (Get-Location).Path -} else { - if (-not $Repo) { - throw "DOSH_REPO is required when running the installer from irm/iex" +function Normalize-Arch { + switch ($env:PROCESSOR_ARCHITECTURE) { + "AMD64" { "x86_64"; break } + "ARM64" { "aarch64"; break } + default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() } } - Require-Command git - $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid()) - git clone --depth 1 $Repo $tmp | Out-Null - $src = $tmp } -try { - Push-Location $src - cargo build --release --bin dosh-client --bin dosh-bench +function Release-ArtifactName { + if ($BinaryName) { + return $BinaryName + } + "dosh-windows-$(Normalize-Arch).zip" +} - $bindir = Join-Path $Prefix "bin" - $configDir = Join-Path $HOME ".config\dosh" - New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null +function Repo-WebBase($Value) { + if (-not $Value) { + return $null + } + $base = $Value.TrimEnd("/") + if ($base.EndsWith(".git")) { + $base = $base.Substring(0, $base.Length - 4) + } + if ($base -notmatch "^https?://") { + return $null + } + $base +} - Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force - Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force - Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force +function Release-DownloadUrl { + if ($BinaryUrl) { + return $BinaryUrl + } + $name = Release-ArtifactName + if ($BinaryBase) { + return "$($BinaryBase.TrimEnd('/'))/$name" + } + $web = Repo-WebBase $Repo + if (-not $web) { + return $null + } + if ($BinaryVersion -eq "latest") { + return "$web/releases/latest/download/$name" + } + "$web/releases/download/$BinaryVersion/$name" +} - $clientConfig = Join-Path $configDir "client.toml" - if ($ForceConfig -or -not (Test-Path $clientConfig)) { - $defaultServer = if ($Server) { $Server } else { "user@example.com" } - $updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" } - $doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" } - @" +function Verify-ArchiveChecksum($Url, $Archive) { + $checksumPath = "$Archive.sha256" + try { + Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath + } + catch { + Write-Warning "prebuilt checksum unavailable; continuing without sidecar verification" + return + } + $expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant() + $actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant() + if ($expected -ne $actual) { + throw "prebuilt checksum mismatch for $Url" + } +} + +$bindir = Join-Path $Prefix "bin" +$configDir = Join-Path $HOME ".config\dosh" +New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null + +function Install-Prebuilt { + $url = Release-DownloadUrl + if (-not $url) { + return $false + } + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid()) + $zip = Join-Path $tmp (Release-ArtifactName) + $extract = Join-Path $tmp "extract" + try { + New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null + Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)" + Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip + Verify-ArchiveChecksum $url $zip + Expand-Archive -Force -Path $zip -DestinationPath $extract + foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) { + $found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1 + if (-not $found) { + throw "prebuilt archive missing $bin" + } + Copy-Item $found.FullName (Join-Path $bindir $bin) -Force + } + Copy-Item (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") -Force + return $true + } + catch { + Write-Warning "prebuilt install failed: $_" + return $false + } + finally { + if (Test-Path $tmp) { + Remove-Item -Recurse -Force $tmp + } + } +} + +function Install-FromSource { + Require-Command cargo + $tmp = $null + if (Test-Path "Cargo.toml") { + $src = (Get-Location).Path + } else { + if (-not $Repo) { + throw "DOSH_REPO is required when running the installer from irm/iex" + } + Require-Command git + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-" + [guid]::NewGuid()) + git clone --depth 1 $Repo $tmp | Out-Null + $src = $tmp + } + + try { + Push-Location $src + cargo build --release --bin dosh-client --bin dosh-bench + Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") -Force + Copy-Item "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") -Force + Copy-Item "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") -Force + } + finally { + Pop-Location + if ($tmp) { + Remove-Item -Recurse -Force $tmp + } + } +} + +if ($UsePrebuilt) { + $ok = Install-Prebuilt + if (-not $ok) { + if ($BinaryRequired) { + throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1" + } + Write-Host "Falling back to source build" + Install-FromSource + } +} else { + Install-FromSource +} + +$clientConfig = Join-Path $configDir "client.toml" +if ($ForceConfig -or -not (Test-Path $clientConfig)) { + $defaultServer = if ($Server) { $Server } else { "user@example.com" } + $updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" } + $doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" } + @" update_repo = "$updateRepo" update_port = $Port server = "$defaultServer" @@ -60,28 +184,23 @@ dosh_port = $Port default_session = "new" reconnect_timeout_secs = 5 view_only = false +predict = true +predict_mode = "experimental" cache_attach_tickets = true credential_cache = "~/.local/share/dosh/credentials" "@ | Set-Content -NoNewline -Encoding utf8 $clientConfig - } - - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - if (-not (($userPath -split ';') -contains $bindir)) { - [Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User") - } - - Write-Host "Installed Dosh client to $bindir" - Write-Host "Configured UDP port $Port" - Write-Host "" - $displayServer = if ($Server) { $Server } else { "user@host" } - Write-Host "Client command:" - Write-Host " $bindir\dosh.exe $displayServer" - Write-Host "" - Write-Host "Open a new terminal for PATH changes to apply." } -finally { - Pop-Location - if ($tmp) { - Remove-Item -Recurse -Force $tmp - } + +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +if (-not (($userPath -split ';') -contains $bindir)) { + [Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User") } + +Write-Host "Installed Dosh client to $bindir" +Write-Host "Configured UDP port $Port" +Write-Host "" +$displayServer = if ($Server) { $Server } else { "user@host" } +Write-Host "Client command:" +Write-Host " $bindir\dosh.exe $displayServer" +Write-Host "" +Write-Host "Open a new terminal for PATH changes to apply." diff --git a/install.sh b/install.sh index f150399..0f765f9 100755 --- a/install.sh +++ b/install.sh @@ -217,10 +217,40 @@ install_extracted_binary() { install -m 0755 "$found" "$3" } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + return 1 + fi +} + +verify_archive_checksum() { + url="$1" + archive="$2" + checksum_file="$3" + if ! curl -fsL "$url.sha256" -o "$checksum_file"; then + echo "prebuilt checksum unavailable; continuing without sidecar verification" >&2 + return 0 + fi + expected="$(awk '{print $1}' "$checksum_file" | sed -n '1p')" + actual="$(sha256_file "$archive")" || { + echo "sha256sum/shasum not found for checksum verification" >&2 + return 1 + } + if [ "$expected" != "$actual" ]; then + echo "prebuilt checksum mismatch for $url" >&2 + return 1 + fi +} + try_install_prebuilt() { download_url="$(release_download_url)" || return 1 tmpdir="$(mktemp -d)" archive="$tmpdir/$(release_artifact_name)" + checksum_file="$archive.sha256" [ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)" need curl need tar @@ -228,6 +258,7 @@ try_install_prebuilt() { echo "prebuilt unavailable: $download_url" >&2 return 1 fi + verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1 mkdir -p "$tmpdir/extract" if ! tar -xzf "$archive" -C "$tmpdir/extract"; then echo "prebuilt archive could not be extracted: $download_url" >&2 diff --git a/scripts/bench-report.sh b/scripts/bench-report.sh new file mode 100755 index 0000000..b5bfc2d --- /dev/null +++ b/scripts/bench-report.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +out="${DOSH_BENCH_REPORT:-target/dosh-bench/report.md}" +iters="${DOSH_BENCH_ITERS:-10}" +server="${DOSH_BENCH_SERVER:-${1:-local}}" +label="${DOSH_BENCH_LABEL:-$(uname -s) $(uname -m)}" + +cargo build --release >/dev/null +mkdir -p "$(dirname "$out")" + +exec target/release/dosh-bench \ + --server "$server" \ + --iterations "$iters" \ + --label "$label" \ + --markdown \ + --output "$out" \ + ${DOSH_BENCH_ARGS:-} diff --git a/scripts/package-release.sh b/scripts/package-release.sh index ece7b05..b694aa9 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -12,6 +12,7 @@ normalize_os() { Darwin) printf '%s\n' macos ;; Linux) printf '%s\n' linux ;; FreeBSD) printf '%s\n' freebsd ;; + MINGW*|MSYS*|CYGWIN*) printf '%s\n' windows ;; *) uname -s | tr '[:upper:]' '[:lower:]' ;; esac } @@ -27,8 +28,13 @@ normalize_arch() { os="$(normalize_os)" arch="$(normalize_arch)" -artifact="dosh-$os-$arch.tar.gz" -versioned_artifact="dosh-$version-$os-$arch.tar.gz" +if [ "$os" = "windows" ]; then + artifact="dosh-$os-$arch.zip" + versioned_artifact="dosh-$version-$os-$arch.zip" +else + artifact="dosh-$os-$arch.tar.gz" + versioned_artifact="dosh-$version-$os-$arch.tar.gz" +fi stage="$out_dir/stage/dosh" cargo build --release @@ -42,11 +48,38 @@ for bin in dosh-client dosh-server dosh-auth dosh-bench; do done printf '%s\n' "$version" >"$stage/VERSION" -tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh +if [ "$os" = "windows" ]; then + if command -v powershell.exe >/dev/null 2>&1; then + powershell.exe -NoProfile -Command "Compress-Archive -Force -Path '$stage' -DestinationPath '$out_dir/$artifact'" + elif command -v zip >/dev/null 2>&1; then + (cd "$out_dir/stage" && zip -qr "../$artifact" dosh) + else + echo "windows packaging requires powershell.exe or zip" >&2 + exit 1 + fi +else + tar -C "$out_dir/stage" -czf "$out_dir/$artifact" dosh +fi cp "$out_dir/$artifact" "$out_dir/$versioned_artifact" +write_sha256() { + file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" >"$file.sha256" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" >"$file.sha256" + else + echo "warning: sha256sum/shasum not found; skipping checksum for $file" >&2 + fi +} + +write_sha256 "$out_dir/$artifact" +write_sha256 "$out_dir/$versioned_artifact" + cat <&2 + exit 2 +fi +shift + +base="${GITEA_URL:-https://git.palav.dev}" +repo="${GITEA_REPO:-Palav/dosh}" +token="${GITEA_TOKEN:-}" +title="${GITEA_TITLE:-$tag}" + +if [ -z "$token" ]; then + echo "GITEA_TOKEN is required" >&2 + exit 2 +fi + +if [ "$#" -eq 0 ]; then + set -- target/dosh-release/dosh-* +fi + +api="$base/api/v1/repos/$repo" +auth_header="Authorization: token $token" + +release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" || true)" +release_id="$(printf '%s\n' "$release_json" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')" + +if [ -z "$release_id" ]; then + payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")" + release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")" + release_id="$(printf '%s\n' "$release_json" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')" +fi + +if [ -z "$release_id" ]; then + echo "could not determine Gitea release id for $tag" >&2 + exit 1 +fi + +for artifact in "$@"; do + if [ ! -f "$artifact" ]; then + continue + fi + name="$(basename "$artifact")" + echo "uploading $name" + curl -fsS \ + -H "$auth_header" \ + -X POST \ + -F "attachment=@$artifact" \ + "$api/releases/$release_id/assets?name=$name" >/dev/null +done diff --git a/src/bin/dosh-bench.rs b/src/bin/dosh-bench.rs index 0d1c8d2..2011d1c 100644 --- a/src/bin/dosh-bench.rs +++ b/src/bin/dosh-bench.rs @@ -2,6 +2,8 @@ use anyhow::{Context, Result, anyhow}; use clap::Parser; use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; use std::ffi::OsStr; +use std::fmt::Write as _; +use std::fs; use std::io::Read; use std::path::PathBuf; use std::process::Command; @@ -63,6 +65,12 @@ struct Args { /// Emit machine-readable JSON (one object per metric, with raw samples). #[arg(long)] json: bool, + /// Emit a publishable Markdown benchmark report. + #[arg(long)] + markdown: bool, + /// Write the report to a file instead of stdout. + #[arg(long)] + output: Option, /// Optional label printed in summary/JSON output (e.g. machine/OS identifier). #[arg(long)] label: Option, @@ -179,11 +187,14 @@ fn main() -> Result<()> { results.push(MetricSamples::new("mosh_start_true_ms", mosh_times)); } - if args.json { - print_json(&args, &results); + let report = if args.json { + render_json(&args, &results) + } else if args.markdown { + render_markdown(&args, &results) } else { - print_table(&args, &results); - } + render_table(&args, &results) + }; + write_report(&args, &report)?; run_assertions(&args, &results)?; Ok(()) @@ -560,17 +571,20 @@ fn percentile(sorted: &[f64], pct: f64) -> f64 { } } -fn print_table(args: &Args, results: &[MetricSamples]) { +fn render_table(args: &Args, results: &[MetricSamples]) -> String { + let mut out = String::new(); if let Some(label) = &args.label { - println!("# label: {label}"); + let _ = writeln!(out, "# label: {label}"); } - println!( + let _ = writeln!( + out, "{:<24} {:>6} {:>9} {:>9} {:>9} {:>9} {:>9}", "metric", "n", "min", "median", "p95", "mean", "max" ); for metric in results { let s = metric.stats(); - println!( + let _ = writeln!( + out, "{:<24} {:>6} {:>9.2} {:>9.2} {:>9.2} {:>9.2} {:>9.2}", metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max ); @@ -578,11 +592,12 @@ fn print_table(args: &Args, results: &[MetricSamples]) { // Raw per-iteration samples, so published numbers can include raw data. for metric in results { let ms: Vec = metric.ms().iter().map(|v| format!("{v:.2}")).collect(); - println!("{} samples_ms=[{}]", metric.name, ms.join(", ")); + let _ = writeln!(out, "{} samples_ms=[{}]", metric.name, ms.join(", ")); } + out } -fn print_json(args: &Args, results: &[MetricSamples]) { +fn render_json(args: &Args, results: &[MetricSamples]) -> String { let mut entries = Vec::new(); for metric in results { let s = metric.stats(); @@ -603,11 +618,58 @@ fn print_json(args: &Args, results: &[MetricSamples]) { Some(label) => format!("\"{}\"", label.replace('"', "\\\"")), None => "null".to_string(), }; - println!( + format!( "{{\"label\":{label},\"iterations\":{},\"metrics\":[{}]}}", args.iterations.max(1), entries.join(",") + ) +} + +fn render_markdown(args: &Args, results: &[MetricSamples]) -> String { + let mut out = String::new(); + let label = args.label.as_deref().unwrap_or("Dosh benchmark"); + let _ = writeln!(out, "# {label}\n"); + let _ = writeln!(out, "- server: `{}`", args.server); + let _ = writeln!(out, "- iterations: `{}`", args.iterations.max(1)); + let _ = writeln!( + out, + "- generated_by: `dosh-bench {}`\n", + env!("CARGO_PKG_VERSION") ); + let _ = writeln!( + out, + "| metric | n | min ms | median ms | p95 ms | mean ms | max ms |" + ); + let _ = writeln!(out, "|---|---:|---:|---:|---:|---:|---:|"); + for metric in results { + let s = metric.stats(); + let _ = writeln!( + out, + "| `{}` | {} | {:.2} | {:.2} | {:.2} | {:.2} | {:.2} |", + metric.name, s.count, s.min, s.median, s.p95, s.mean, s.max + ); + } + let _ = writeln!(out, "\n## Raw Samples\n"); + for metric in results { + let ms: Vec = metric.ms().iter().map(|v| format!("{v:.2}")).collect(); + let _ = writeln!(out, "- `{}`: [{}]", metric.name, ms.join(", ")); + } + out +} + +fn write_report(args: &Args, report: &str) -> Result<()> { + if let Some(path) = &args.output { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::write(path, report).with_context(|| format!("write {}", path.display()))?; + println!("wrote {}", path.display()); + } else { + println!("{report}"); + } + Ok(()) } fn metric_mean(results: &[MetricSamples], name: &str) -> Option { @@ -677,3 +739,69 @@ fn default_client_path() -> PathBuf { .and_then(|path| path.parent().map(|parent| parent.join("dosh-client"))) .unwrap_or_else(|| PathBuf::from("dosh-client")) } + +#[cfg(test)] +mod tests { + use super::*; + + fn args_for_render() -> Args { + Args { + server: "local".to_string(), + session: "default".to_string(), + ssh_port: 22, + dosh_port: 50000, + dosh_host: None, + iterations: 2, + local_auth: true, + cold_native: false, + cached_ticket: false, + resume: false, + client: None, + ssh_auth_command: "~/.local/bin/dosh-auth".to_string(), + ssh_key: None, + ssh_known_hosts: None, + ssh_control_path: None, + controlmaster: false, + no_cache: false, + warm_cache: false, + skip_ssh_baseline: true, + include_mosh: false, + mosh: PathBuf::from("mosh"), + mosh_server_command: "mosh-server".to_string(), + mosh_port: None, + json: false, + markdown: true, + output: None, + label: Some("test host".to_string()), + assert_ssh_plus_ms: None, + assert_mosh_minus_ms: None, + assert_dosh_max_ms: None, + } + } + + #[test] + fn markdown_report_contains_summary_and_samples() { + let args = args_for_render(); + let metrics = vec![MetricSamples::new( + "dosh_cached_attach_ms", + vec![Duration::from_millis(3), Duration::from_millis(5)], + )]; + let report = render_markdown(&args, &metrics); + assert!(report.contains("# test host")); + assert!(report.contains("| `dosh_cached_attach_ms` | 2 | 3.00")); + assert!(report.contains("- `dosh_cached_attach_ms`: [3.00, 5.00]")); + } + + #[test] + fn json_report_contains_metrics() { + let args = args_for_render(); + let metrics = vec![MetricSamples::new( + "dosh_local_attach_ms", + vec![Duration::from_millis(7)], + )]; + let report = render_json(&args, &metrics); + assert!(report.contains("\"label\":\"test host\"")); + assert!(report.contains("\"metric\":\"dosh_local_attach_ms\"")); + assert!(report.contains("\"samples_ms\":[7.0000]")); + } +} diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 3eed8e5..5d49ac8 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -67,11 +67,11 @@ fn terminal_size() -> (u16, u16) { } #[derive(Debug, Parser)] -#[command(name = "dosh-client")] +#[command(name = "dosh-client", version)] struct Args { #[arg()] server: Option, - #[arg()] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] command: Vec, #[arg(long)] session: Option, @@ -192,7 +192,7 @@ async fn main() -> Result<()> { let args = Args::parse(); let config = load_client_config(None).unwrap_or_default(); if args.server.as_deref() == Some("update") { - return run_update(&config); + return run_update(&config, update_check_requested(&args.command)?); } if args.server.as_deref() == Some("import-ssh") { return run_import_ssh(&config, &args.command); @@ -1079,13 +1079,75 @@ fn run_sessions_command( Ok(()) } -fn run_update(config: &dosh::config::ClientConfig) -> Result<()> { +fn update_check_requested(command: &[String]) -> Result { + match command { + [] => Ok(false), + [flag] if flag == "--check" || flag == "-n" => Ok(true), + _ => Err(anyhow!("usage: dosh update [--check]")), + } +} + +fn release_artifact_name() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "dosh-macos-aarch64.tar.gz", + ("macos", "x86_64") => "dosh-macos-x86_64.tar.gz", + ("linux", "aarch64") => "dosh-linux-aarch64.tar.gz", + ("linux", "x86_64") => "dosh-linux-x86_64.tar.gz", + ("windows", "x86_64") => "dosh-windows-x86_64.zip", + ("windows", "aarch64") => "dosh-windows-aarch64.zip", + _ => "dosh-unknown.tar.gz", + } +} + +fn latest_release_download_url(repo: &str, artifact: &str) -> Option { + let web = repo + .strip_suffix(".git") + .unwrap_or(repo) + .trim_end_matches('/'); + if !web.starts_with("http://") && !web.starts_with("https://") { + return None; + } + Some(format!("{web}/releases/latest/download/{artifact}")) +} + +fn url_reachable(url: &str) -> Result { + let status = Command::new("curl") + .arg("-fsIL") + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("check {url}"))?; + Ok(status.success()) +} + +fn run_update(config: &dosh::config::ClientConfig, check_only: bool) -> Result<()> { let repo = config .update_repo .clone() .unwrap_or_else(|| "https://git.palav.dev/Palav/dosh.git".to_string()); let raw_base = raw_base_from_repo(&repo); let installer = format!("{raw_base}/raw/branch/main/install.sh"); + let artifact = release_artifact_name(); + let binary_url = latest_release_download_url(&repo, artifact); + if check_only { + println!("dosh {}", env!("CARGO_PKG_VERSION")); + println!("repo: {repo}"); + println!("installer: {installer}"); + if let Some(binary_url) = &binary_url { + let status = if url_reachable(binary_url)? { + "available" + } else { + "missing" + }; + println!("prebuilt: {status} ({artifact})"); + println!("prebuilt_url: {binary_url}"); + } else { + println!("prebuilt: unavailable for non-HTTP repo"); + } + return Ok(()); + } let update_cache = dirs::cache_dir() .unwrap_or_else(|| expand_tilde("~/.cache")) .join("dosh") @@ -4286,12 +4348,12 @@ const TERMINAL_CLEANUP: &[u8] = concat!( mod tests { use super::{ DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor, - RemoteForward, SshConfig, StatusAction, auth_allows, + RemoteForward, SshConfig, StatusAction, auth_allows, latest_release_download_url, load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until, render_status_clear, render_status_overlay, requested_env, resolved_startup_command, ssh_destination_host, ssh_username, ssh_with_user, startup_command, - toml_bare_key_or_quoted, + toml_bare_key_or_quoted, update_check_requested, }; use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::native::EnvVar; @@ -4839,6 +4901,30 @@ mod tests { ); } + #[test] + fn update_check_accepts_only_check_flag() { + assert!(!update_check_requested(&[]).unwrap()); + assert!(update_check_requested(&["--check".to_string()]).unwrap()); + assert!(update_check_requested(&["-n".to_string()]).unwrap()); + assert!(update_check_requested(&["--wat".to_string()]).is_err()); + assert!(update_check_requested(&["--check".to_string(), "extra".to_string()]).is_err()); + } + + #[test] + fn release_download_url_uses_latest_release_asset() { + assert_eq!( + latest_release_download_url( + "https://git.palav.dev/Palav/dosh.git", + "dosh-linux-x86_64.tar.gz" + ) + .unwrap(), + "https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-linux-x86_64.tar.gz" + ); + assert!( + latest_release_download_url("git@git.palav.dev:Palav/dosh.git", "artifact").is_none() + ); + } + #[test] fn resolved_startup_command_expands_global_extension() { let mut config = ClientConfig::default();