Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a8faafb
ci: add a manual Linux watch-soak workflow (#129, #318, #320)
dean0x Sep 9, 2026
fc83b1d
test(watch): RED — rename-into-place must trigger a rebuild; temp fil…
dean0x Sep 13, 2026
b055957
test(watch): add common::write_atomic (temp + rename, one FS event)
dean0x Sep 13, 2026
04b01a6
test(watch): route every watched write through write_atomic
dean0x Sep 13, 2026
fb3cd9b
test(watch): RED — StderrTap::bytes can read a truncated buffer (#320)
dean0x Sep 13, 2026
5b343fc
test(watch): join the drain thread — PipeTap::finish; ChildGuard move…
dean0x Sep 13, 2026
cffb951
test(watch): replace 12 of the 13 post-kill flush sleeps with finish …
dean0x Sep 13, 2026
f145067
test(watch): RED — spawn_watch_ready deadlocks on a large piped stdou…
dean0x Sep 13, 2026
11c8a43
test(watch): drain stdout before the readiness wait (#320)
dean0x Sep 13, 2026
0b959b2
test(watch): RED — i16–i20 sample stderr before the dir-mode emit can…
dean0x Sep 13, 2026
57084a8
test(watch): wait for the expected warning count before asserting it …
dean0x Sep 13, 2026
3373f1f
test(watch): pin quiet-period and capped debounce semantics (#379)
dean0x Sep 13, 2026
f3fb1a0
fix(watch): make --debounce a quiet period with a hard cap (#379)
dean0x Sep 13, 2026
3caf629
docs(watch): document the debounce quiet period and cap (#379)
dean0x Sep 13, 2026
f78dd7c
test(watch): prove the readiness handshake makes ctrl-c deterministic…
dean0x Sep 13, 2026
a27bac9
test(build): synchronise the bare-filename watch test on MDS_TEST_REA…
dean0x Sep 13, 2026
214ff30
docs: record the C2 evidence (#129, #318, #320)
dean0x Sep 13, 2026
d4bdd2c
fix(watch): cover the Disconnected debounce exit (scrutinize)
dean0x Sep 13, 2026
b537078
docs(watch): correct two C2 notes (scrutinize P2)
dean0x Sep 13, 2026
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
194 changes: 194 additions & 0 deletions .github/workflows/watch-soak.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
name: Watch soak (manual)

# MANUAL reproduction instrument for the cli_watch flake family (#129 / #318 / #320).
#
# NOT a gate. `workflow_dispatch` is the only trigger, so this workflow publishes
# zero check-runs on any pull-request head: it cannot enter branch protection and
# cannot appear in scripts/verify-pr-checks.mjs's tally. It touches none of the six
# release-surface paths and does not modify release.yml.
#
# It runs CI's exact command -- `cargo test`, deliberately NOT nextest, because the
# process-per-test model nextest uses is a different execution environment and this
# suite's failures are environment-sensitive. ubuntu-latest only: macOS FSEvents
# cannot reproduce this bug class at all (see .devflow/learning/pitfalls.md PF-026
# and the project-watch-tests-flaky memory -- a green macOS run proves nothing).
#
# Runs every iteration and tallies, rather than aborting on the first red, because
# the quantity of interest is a RATE. "Failed at iteration 3" cannot distinguish
# 1/20 from 20/20, and the before/after control this instrument exists to serve
# (PF-027 resolution 6) needs both numbers.

on:
workflow_dispatch:
inputs:
iterations:
description: 'How many times to run the cli_watch suite (1-200)'
type: string
default: '20'
filter:
description: 'Optional cargo-test name filter (empty = the whole suite)'
type: string
default: ''

permissions:
contents: read

# cancel-in-progress: false is load-bearing. A soak run is a MEASUREMENT; cancelling
# one halfway leaves a partial tally indistinguishable from a clean run with fewer
# iterations. run_id is in the group key so two deliberate dispatches on the same
# ref never evict each other.
concurrency:
group: watch-soak-${{ github.ref }}-${{ github.run_id }}
cancel-in-progress: false

env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1

jobs:
soak:
name: Watch soak (${{ matrix.label }})
runs-on: ubuntu-latest
# The loop is bounded by `iterations`, but a hung child inside cargo test is not.
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
# Default features: the same build CI's `rust` job runs.
- label: default
feature: ''
# The #317 probe widens the publish->arm window to 200ms; N iterations turn
# "6/6 green with the probe on" into a rate.
- label: startup-race-probe
feature: 'startup-race-probe'
steps:
- uses: actions/checkout@v7

- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable

- uses: Swatinem/rust-cache@v2
with:
# Per-leg key (PF-041): the two legs build different feature sets.
key: watch-soak-${{ matrix.label }}

- name: Validate the iterations input
env:
ITERATIONS: ${{ inputs.iterations }}
run: |
set -euo pipefail
case "$ITERATIONS" in
''|*[!0-9]*)
echo "::error::iterations must be a positive integer; got '$ITERATIONS'"
exit 1
;;
esac
if [ "$ITERATIONS" -lt 1 ] || [ "$ITERATIONS" -gt 200 ]; then
echo "::error::iterations must be between 1 and 200; got $ITERATIONS"
exit 1
fi
echo "iterations validated: $ITERATIONS"

# Compile once so the loop measures the SUITE and not rustc.
- name: Build the test binary once
env:
FEATURE: ${{ matrix.feature }}
run: |
set -euo pipefail
args=(build -p mds-cli --tests)
if [ -n "$FEATURE" ]; then
args+=(--features "$FEATURE")
fi
echo "cargo ${args[*]}"
cargo "${args[@]}"

- name: Soak
id: soak
env:
ITERATIONS: ${{ inputs.iterations }}
FILTER: ${{ inputs.filter }}
FEATURE: ${{ matrix.feature }}
LABEL: ${{ matrix.label }}
run: |
# -e is DELIBERATELY omitted: a non-zero `cargo test` is the DATA this
# step collects, not an error that should abort it. -u and pipefail stay.
set -uo pipefail

mkdir -p soak

args=(test -p mds-cli --test cli_watch)
if [ -n "$FEATURE" ]; then
args+=(--features "$FEATURE")
fi
if [ -n "$FILTER" ]; then
args+=(-- "$FILTER")
fi
echo "command: cargo ${args[*]}"

pass=0
fail=0
failed_iters=""
i=1
while [ "$i" -le "$ITERATIONS" ]; do
log="soak/iter-$(printf '%03d' "$i").log"
if cargo "${args[@]}" > "$log" 2>&1; then
pass=$((pass + 1))
rm -f "$log"
printf 'iter %3d PASS\n' "$i"
else
fail=$((fail + 1))
failed_iters="$failed_iters $i"
printf 'iter %3d FAIL\n' "$i"
echo "::warning title=watch-soak::iteration $i failed on leg $LABEL"
# Surface the discriminator inline (PF-026: the panic's file:line, not
# the test's name, is the diagnosis).
grep -E 'panicked at|\.\.\. FAILED|test result: FAILED' "$log" || true
fi
i=$((i + 1))
done

# The summary file is ALWAYS written, so the artifact is never empty (PF-016).
{
echo "leg: $LABEL"
echo "ref: $GITHUB_REF"
echo "sha: $GITHUB_SHA"
echo "command: cargo ${args[*]}"
echo "iterations: $ITERATIONS"
echo "passed: $pass"
echo "failed: $fail"
echo "failed at: $failed_iters"
} > soak/summary.txt
cat soak/summary.txt

{
echo "### Watch soak - $LABEL"
echo ""
echo "| metric | value |"
echo "| --- | --- |"
echo "| ref | \`$GITHUB_REF\` |"
echo "| sha | \`$GITHUB_SHA\` |"
echo "| command | \`cargo ${args[*]}\` |"
echo "| iterations | $ITERATIONS |"
echo "| passed | $pass |"
echo "| **failed** | **$fail** |"
if [ "$fail" -gt 0 ]; then
echo "| failing iterations |$failed_iters |"
fi
} >> "$GITHUB_STEP_SUMMARY"

if [ "$fail" -gt 0 ]; then
echo "::error::$fail of $ITERATIONS iterations failed on leg $LABEL"
exit 1
fi
echo "clean soak: $pass/$ITERATIONS passed on leg $LABEL"

- name: Upload failing logs and the tally
if: always()
uses: actions/upload-artifact@v7
with:
name: watch-soak-${{ matrix.label }}-${{ github.run_id }}
path: soak/
# `error`, not `ignore`: summary.txt is always written, so an empty upload
# means the glob is wrong, not that the soak was clean (PF-016).
if-no-files-found: error
retention-days: 14
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **`mds watch --debounce` is now a quiet period with a hard cap (#379).**
Each content event restarts the window instead of the window expiring at a fixed
offset from the first event, so a save burst longer than the window coalesces into
one rebuild; the window is bounded by `max(10 x window, 1 s)` and 10 000 events so a
file written to continuously still rebuilds and the idle-tick liveness probe cannot
be starved; raw values are clamped to 60 s (`--debounce 18446744073709551615`
previously watched forever without ever rebuilding); `--debounce 0` still means no
coalescing. No new output. Known cost, in both modes: an event that is not the edit
you care about can still extend an open window, because relevance is not re-derived
per message inside it — in directory mode every event also *opens* one (events under
excluded directories are filtered only afterwards), while in file mode the entry's
parent directory is watched non-recursively, so a sibling scratch write by an editor
extends a window a real edit has already opened. Either way `npm install` churn or a
noisy editor can delay a real edit and the idle tick by up to the cap.

### Fixed

- **Warn on duplicate keys in `--vars` JSON files, at every depth, on every `mds watch` rebuild that writes output (#326).**
Expand Down Expand Up @@ -60,6 +77,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `build-napi` per-leg rust-cache key: adds `key: ${{ matrix.settings.target }}` to the `Swatinem/rust-cache` step so each cross-compile leg's target artifacts stay isolated (PF-041; without the key all four ubuntu legs and both macOS legs restored one shared blob, confirmed live in run 34065573775); `build-python`'s existing `key: matrix.target-matrix.manylinux` (#347) unchanged; spec S20 in `release-auth-probe.spec.mjs` pins both and fails `Version gate` if a key is dropped; spec S3 extended to pin the `-z` CARGO_REG_TOKEN guard in executable code; #345 verified that crates.io `GET /api/v1/me` is `AuthCheck::only_cookie()` (HTTP 403 for any API token) and the only token-accepting read route rejects scoped tokens — non-empty guard is the strongest check available, durable fix tracked in #368; #345 closed won't-fix-as-filed (#345 #352).
- Alpine `node:22-alpine` load tests for both musl napi addons gate `publish-crates`: x64 (`linux-x64-musl`) as the last step of `stage-and-verify-napi` (after the staged artifact upload, so the artifact is never suppressed by an x64 failure), arm64 (`linux-arm64-musl`) in a new unguarded `load-test-musl-arm64` job on a native `ubuntu-24.04-arm` runner using the `napi-staged` artifact; both use `scripts/musl-load-probe.cjs` in a `docker run --network none` step with a positive control; `publish-crates` blocks on both via `needs:` AND its `if:` conjunct (PF-047); spec S21 in `release-auth-probe.spec.mjs` pins job existence, runner, guard shape, wiring, step order, and run-block byte-equality (#340); the first CI run surfaced #371 (string compile fails when the base directory is a filesystem root — `node:22-alpine` has no `WORKDIR` so the default container cwd is `/`); the gate now runs the container from `/w` (`docker run -w /w`) and the probe asserts its cwd so a dropped flag fails loudly.
- Both musl napi legs (`x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl`) now cross-compile with `napi build … -x` / cargo-zigbuild 0.23.0: the SHA-pinned `taiki-e/install-action` (v2.85.10, `fallback: none`) installs cargo-zigbuild before `Swatinem/rust-cache` (rust-cache deletes `~/.cargo/bin` on save; napi's detector is presence-only and would `cargo install` an unpinned copy mid-build otherwise); the hand-written zig cc wrappers, fake-zig self-check, and both `CARGO_TARGET_*_MUSL_LINKER` exports are deleted; three new steps assert the pinned version (before and after the build) and the no-op detector reads both musl linker vars inside `[ -z ]` guards to confirm none is set; the readelf gate adds `ALLOWED_NEEDED='libc\.so|libgcc_s\.so\.1'` with a planted `libunwind.so.1` control; `mlugg/setup-zig` SHA-pinned (v2.2.1) in the same step; spec S22 in `release-auth-probe.spec.mjs` pins all of the above (#339).
- manual `watch-soak.yml` Linux soak instrument for the cli_watch flake family (#129 #318 #320); `workflow_dispatch` only, not a gate, not a required context, not release-surface
- `cli_watch` harness: every post-spawn write to a watched path goes through `common::write_atomic` (temp + rename, one FS event instead of the truncate-then-write pair whose 0-byte intermediate was compiled at `--debounce 0`); 45 sites converted by a mechanical rule stated in the file's doc comment, with two `// DELIBERATE:` plain-write exceptions whose subject IS the truncate+write pair (#318).
- `cli_watch` harness: the pipe drain thread is now joinable — `PipeTap::finish`/`finish_text` reap the child and then JOIN the drain, so the final stderr read carries a happens-before edge to the child's last write; 13 of 13 post-kill flush sleeps deleted and `ChildGuard` moved to `tests/common` so `finish` can name it (#320).
- `cli_watch` harness: a piped stdout is drained *before* the readiness wait, not after (`spawn_watch_ready` returns the tap as a third element; `spawn_ready_piped_stdout` hands it to the caller). `mds watch -o -` publishes its startup output before it writes the readiness marker, so an undrained pipe filled and blocked the child while the poller waited for a marker that could never arrive — reproduced locally as a deterministic 10s `READY_TIMEOUT` failure on 512 KiB of stdout (#320).
- `cli_watch` harness: the i16–i20 duplicate-vars-warning family waits for the expected warning count with a bounded `wait_for_stderr_count` before asserting it. In directory mode the warning is emitted after the output write, so sampling stderr the instant the artifact appeared could read one warning short (CI runs 34366009518, 34404318888) (#326 #320).
- `cli_watch`: `watch_readiness_handshake_makes_ctrl_c_exit_deterministic` is a two-arm control (20 iterations) proving the `MDS_TEST_READY` handshake, not luck, is what makes a post-SIGINT `status.success()` deterministic — unsynchronized spawn signalled on the `Watching …` line dies by SIGINT; a spawn signalled after the handshake exits 0 and prints `Stopped watching.` (#129).
- `cli_build`: `watch_bare_filename_from_cwd_succeeds` is synchronised on the readiness handshake and reads `hello.md` once, instead of polling the output artifact for up to 10s; the private `ChildGuard` copy is replaced by `common::ChildGuard` and stderr is drained rather than discarded (#318).

## [0.4.2] — 2026-09-03

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,12 @@ Build/Watch options:

Watch-only options:
--clear Clear terminal before each rebuild (only when stderr is a TTY)
--debounce <MS> Debounce window in milliseconds (default: 100)
--debounce <MS> Quiet period in milliseconds before a rebuild (default: 100).
Each file change restarts the window, so a save burst longer
than MS still coalesces into a single rebuild. The window is
capped at max(10 × MS, 1000) ms, so a file written to
continuously still rebuilds. 0 disables coalescing (every
event rebuilds). Values above 60000 are clamped.
--poll-interval <MS> Liveness-probe interval in milliseconds (default: 1000).
0 disables self-heal (native events only). Clamped to ≥50ms.
The watcher self-heals after a watched dir/root is deleted and
Expand Down
7 changes: 5 additions & 2 deletions crates/mds-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,11 @@ enum Commands {
/// Clear the terminal before each rebuild (only when stderr is a TTY)
#[arg(long)]
clear: bool,
/// Debounce window in milliseconds (default 100; use 0 for immediate rebuilds).
/// Controls how long to wait for burst coalescing after the first event.
/// Quiet period in milliseconds before a rebuild (default 100).
/// Each file change restarts the window, so a save burst longer than MS still
/// coalesces into one rebuild; the window is capped at max(10 x MS, 1000) ms so
/// continuous writes still rebuild. Use 0 to disable coalescing.
/// Values above 60000 are clamped.
#[arg(long = "debounce", value_name = "MS", default_value = "100")]
debounce: u64,
/// Self-heal poll interval in milliseconds (default 1000).
Expand Down
19 changes: 19 additions & 0 deletions crates/mds-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,25 @@ mod tests {
assert_eq!(result, PathBuf::from("/out/page.md"));
}

/// The `.<name>.tmp-<pid>-<n>` temp files an atomic write leaves in flight must
/// never be collected as sources. The suffix sits AFTER the `.mds`, so
/// `Path::extension()` is the `tmp-…` component and the walker's extension gate
/// rejects it — the same gate the dir-mode watch filter uses.
///
/// The second half is the non-vacuity control: a name whose `.mds` is genuinely
/// last IS collected, so the first assertion is not passing on an empty walk.
#[test]
fn collect_mds_files_ignores_write_atomic_temp_names() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("t.mds"), "real").unwrap();
std::fs::write(dir.path().join(".t.mds.tmp-4242-7"), "in flight").unwrap();
let files = collect_mds_files(dir.path(), 64, None);
assert_eq!(files.len(), 1, "temp file must not be collected: {files:?}");
// Non-vacuity: the inverted name IS collected.
std::fs::write(dir.path().join(".tmp-4242-8.t.mds"), "wrong shape").unwrap();
assert_eq!(collect_mds_files(dir.path(), 64, None).len(), 2);
}

#[test]
fn is_partial_detects_underscore_prefix() {
assert!(is_partial(Path::new("/dir/_partial.mds")));
Expand Down
Loading
Loading