Skip to content

CI only: diagnose leave_residue on Windows — do not merge - #51

Closed
mgasner wants to merge 27 commits into
mainfrom
ci-diag/windows-residue
Closed

mgasner wants to merge 27 commits into
mainfrom
ci-diag/windows-residue

Conversation

@mgasner

@mgasner mgasner commented Oct 14, 2026

Copy link
Copy Markdown

CI only, do not merge. Runs the Windows yas_process tests three times with diagnostics to find out why leave_residue_* hangs on the windows-2025 20261007 runner image.

View in Indent
Tag @indent to continue the conversation here.

jsegaran and others added 27 commits September 30, 2026 12:18
Adds `YasConnection.onRequestFailure(listener)`, so an embedder can observe every Request whose promise rejects in one place, without wrapping each family client call site.

The listener receives `{ family, kind, error }` and runs synchronously, before the rejection reaches the caller. It fires for:

- a non-OK Result (`YasResultError`)
- a Result body that fails to decode
- a Request the server does not advertise
- a frame that could not be written
- a session lost with the Request still in flight

Non-OK statuses returned through `requestResult` resolve as envelopes and are not reported.

The hook goes on the pending request's `reject` rather than on a `.catch` around the returned promise, so resolution timing does not change. Listener exceptions go through the existing `reportListenerError`.

Indent's web client will use this to send YAS errors caused by our own code to Sentry. Consuming it in neo needs an `@yas-run/core` release that includes this change.

Tests: `vitest run src/__tests__/yas.test.ts` passes, including the new case. The new case fails without the change. `tsc --noEmit` is clean.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a0f349-8504-7879-b4ae-c674c1c42f19)
Tag `@indent` to continue the conversation here.
…as-run#95)

`FsRoot::write_in_place` writes as Node's `writeFile` does (same inode, owner and mode, through a final symlink), but always with `Precondition::Any`. A client that reads a file, changes it and writes it back can't make that a compare-and-swap without giving up in-place semantics: `write` replaces the file and sets its mode.

This adds `write_in_place_if(path, content, precondition)`, used by both paths (the one-`APPLY` inline write and the staged `STAGE_IN_PLACE` + `COMMIT`). The server already checks the precondition together with the write under `MUTATION_LOCK`, so nothing changes server-side or on the wire. `write_in_place` becomes `write_in_place_if(…, Precondition::Any)`.

The entry checked is the one at `path` itself (stat is `lstat`), so callers name the file a link resolves to. The doc comment says so.

**Why:** Ultimator's `edit_file` will pass `Precondition::Hash` of what it read. An edit then never overwrites a change made between its read and its write (an editor's save, a command's output): on `CONFLICT` it reads again and makes the edit on what the file holds then.

**Tests**
- `crates/cli/tests/client_host.rs`, in the existing in-place block, for both inline-sized and staged content:
  - a stale hash is a `CONFLICT` with no `os_error`, its `conflict_detail` carries the current hash, and the file is left unchanged;
  - the current hash writes, keeping the inode.
- Ultimator's `crates/ultimator-computer/tests/yas.rs` uses this branch against a real `yas` server, at both sizes. The same test fails when the precondition is replaced by `Any`.

I didn't run `client_host.rs` itself locally: there wasn't room for a full `yas-cli` test build on the host.

**CI note:** `build-windows` fails for a reason unrelated to this change. The runner picked up Rust 1.99.0 (released 09-28), which deprecates `Atomic*::fetch_update` (renamed `try_update`), and `-D warnings` turns that into errors at 8 existing call sites in `crates/server` (`net.rs:1158`, `yas.rs:125`, `yas_fs.rs:923`, `yas_lsp.rs:2079`, `lib.rs:13071`, `extension_store.rs:722/776/787`). The same job passed on `main` (d46a369) with 1.98.1. `try_update` doesn't exist in 1.98, which the flake still uses, so the fix belongs in a separate change: pin the job's toolchain, or `#[allow(deprecated)]` at those call sites until the flake moves to 1.99.
…-01 (yas-run#96)

Rust 1.99.0 (released 10-01) deprecates `Atomic*::fetch_update`, renamed `try_update` (same arguments, same `Result`). `build-windows` takes `dtolnay/rust-toolchain@stable` with `RUSTFLAGS=-D warnings`, so it now fails on the ten calls in `crates/server` (seen on yas-run#95; the same job passed on d46a369 with 1.98.1).

- The ten calls become `try_update`, which 1.98.1 already has: the change builds on both toolchains.
- `flake.lock`: rust-overlay 09-14 → 10-01, so `rust-bin.stable.latest` (nix/common.nix) is 1.99.0, as on the runners.

Checked: `bin/lint --check` passes on 1.99.0 (fmt, prettier, protocol, every clippy pass with `-D warnings`). Windows-only code is left to CI's build-windows.
The Release run for v0.5.0 ([36767936860](https://github.com/yas-run/yas/actions/runs/36767936860)) failed two jobs with exit 101, so the release, publish-crates, publish-npm and publish-bin-npm jobs were skipped. Each failure was a race. The first is in a test; the second is in `yas-git`'s state engine.

## coverage: `yas-cli` client_host `the_process_catalogue_can_be_watched`

It panicked at `client_host.rs:1375` with `Elapsed`. Locally it hangs about 1 run in 10.

- **Cause:** the server's process-catalogue watch (`run_process_watch`) diffs snapshots, and an ordinary child leaves the catalogue as soon as its EXIT is delivered. A short `true` could start and end between two snapshots and never show.
- **Fix:** test only. The test now runs `cat` with stdin piped, waits until the watch shows it running, then closes stdin, waits for it, and expects an update with its exit or its removal. It uses a `next()` helper with a TIMEOUT.
- **Check:** 120 of 120 runs passed with 24 running at once.

## macOS: `yas-git` engine `index_changes_reconcile_tracked_exceptions_to_ignore_pruning`

It failed at `engine.rs:469`: `assertion failed: watches().contains(&repository.0.join("generated/nested"))`.

- **Cause:** the engine updated the pruned worktree watch set against the index only when an event for `.git/index` itself arrived (`handle_event` sets `worktree_stale`). The status pipeline, meanwhile, notices a changed index by its fingerprint (`StatusMemo.index_sig`) on any settle, a ref-side one included. FSEvents can deliver `index.lock` before the rename's `index`. A snapshot could then show the force-added `generated/nested/tracked` while its ignored directory wasn't watched yet, and writes there went unseen until some later event.
- **Fix** (`crates/git/src/state.rs`):
  - `Arms` records the index fingerprint the pruned set was cut against (`worktree_index`).
  - `sync_watches_pass`, which runs before every snapshot, compares it with `file_sig(index)`. When it moved, it rebuilds the exclude stack and reconciles the set.
  - The watch set is thereby always at least as fresh as the status it goes with. Unpruned sets ignore the index, as before.
- **Check:** Linux doesn't hit this ordering, so the test passing there (200 of 200 runs with 8 at a time) only shows nothing regressed. It doesn't prove the macOS fix; this PR's macOS job does.

## Checks

- `cargo test --workspace` passed 4 times, apart from `yas-website`'s `serves_assets_with_cache_headers_and_etags`, which fails only because of the local placeholder `js/web/dist`.
- `yas-git` engine tests: 13 of 13.
- `cargo clippy -p yas-git --all-targets -D warnings` and `cargo fmt` are clean.

## Not in this PR

- `a_server_configured_for_more_processes_runs_them_on_one_session` failed 1 run in 15 under load with `ResourceExhausted` ("Process output fell a window behind its reader and was dropped"). It looks like an exit lost when the endpoint queue is full: `send_exit` uses `try_send`. That is a server design issue, left for later.
- Since the publish jobs would meet the 0.5.0 crates and npm packages already published by hand, finishing the release probably means tagging 0.5.1 with this, or rerunning the failed jobs of the 0.5.0 run.
## Summary

Recycle Process admission **before** a peer can observe the completion signals that let it immediately submit a replacement.

- Final Transfer CLOSE is now published by the connection task after stream/attachment and outbound accounting retire. EXIT bookkeeping is queued before output senders close, and the connection owns exit-report publication.
- Native terminal dispatch prepares private final-ACK safety and a discoverable pending terminal, then retires bindings and waits for the all-guards generation/owner-budget fence before publishing replay, WAIT, or EXIT. Endpoint writers continue draining while terminal publications wait, avoiding cross-generation watcher deadlocks.
- Preserve authoritative WAIT through failed/detached routes and the retirement-to-publication gap; prevent deferred publication or late SPAWN settlement from resurrecting a closed session's replay. Catalogue and owner admission retire atomically under the existing server → endpoint lock order.
- Keep queued output, cancellation, operation replay, final ACKs, and detachable retained-final accounting intact. No protocol, capacity, dependency, or client concurrency changes.

## Deterministic regressions

Five new tests exercise full small-capacity replacement, delayed native preparation/retirement/publication with a second watcher, failed routes before/during terminal preparation, final ACK after binding removal but before publication, cancelled WAIT/replay, late settlement after shutdown, and withheld internal final-CLOSE handling while incoming SPAWN executes. No sleeps, retries, relaxed admission, or integration-test weakening.

## Qualification

Exact reviewed commit: `d13fd9c4f0dbf03d3fcd4062f99859ccee2b3bb6`, based deliberately on the downstream pinned `685e781c76fd1910c61bff237e64123fbf37ca59`; unrelated upstream yas-run#97 was not folded into this pin candidate.

| Check | Result |
| --- | --- |
| Focused native lifecycle | 24 passed |
| Focused wire Process | 7 passed |
| Full default `yas-server` | 947 passed, 0 failed, 15 ignored |
| Workspace all-target tests, no default features, excluding `yas-website` | 2069 passed, 0 failed, 31 ignored |
| Strict server Clippy, default and no-default features | Passed, `-D warnings` |
| Strict workspace all-target Clippy, no-default features, excluding website | Passed, `-D warnings` |
| Workspace format / protocol artifact checks | Passed |
| Built candidate YAS CLI (`openh264,mimalloc`, no browser UI) | Passed |
| Unchanged downstream Ultimator YAS integration against explicit candidate binary | 22 passed, 0 failed, no skips |

The unchanged integration includes forty concurrent commands plus eighty file calls at the original advertised 16-process / 8-pending-SPAWN limits. Independent read-only review approved the exact four source/test fingerprints.

### Local check caveats

All work and checks used an isolated sandbox, two CPUs and two Cargo build jobs. Initial broad-check failures were traced to the **outer PTY's inherited test configuration**: Channel was disabled, and default-process tests inherited raised maxima. Qualification explicitly enabled scratch-test Channel and removed inherited Process settings; each configured-capacity test and the unchanged downstream fixture still applied its own overrides. Initial attempts and exact-source fingerprints were retained in a durable manifest.

`yas-website` was excluded because its unrelated built `js/web/dist` assets are absent. The optional x264 all-features variant was not run because its development package is unavailable. Nix-driven frontend/E2E, Windows and macOS checks were not run locally. No hosts/live services were changed or restarted, and nothing was merged.

<sub>Opened from [an Ultimator session](https://ultimator.app/sessions/humpipocvpeo2frw)</sub>
…as-run#99)

This is the YAS part of Ultimator's database connectors. A connector is a YAS server in a customer's network. It uplinks outbound and offers only the Net family, and only to the targets its operator lists. Ultimator's service worker reaches it with `yas-client` and opens TCP flows to the database.

## 1. A public Net client: `yas_client::net`
The CLI's `pub(crate)` Net client (`crates/cli/src/yas_net.rs`) now lives in `yas-client`, built on the multiplexed `Client`. Net flows share the session with the other families.

```rust
impl Client { pub fn net(&self) -> Result<Net> }   // Error::Unsupported without Net + Transfer
impl Net {
    pub async fn open_tcp(&self, host: &str, port: u16) -> Result<NetStream>;
    pub async fn open_tcp_with(&self, host: &str, port: u16, options: &StreamOptions) -> Result<NetStream>;
    pub async fn open_stream(&self, address: Address, options: &StreamOptions) -> Result<NetStream>;
    pub async fn open_udp(&self, host: &str, port: u16) -> Result<DatagramFlow>;
    pub fn limits(&self) -> &Limits;
}
pub struct StreamOptions { pub tls: Option<Tls>, pub early_data: Vec<u8>, pub receive_window: Option<u64>, pub timeout: Option<Duration> }
// NetStream: tokio AsyncRead + AsyncWrite + Unpin + Send
impl Error { pub fn net_failure(&self) -> NetFailure }  // Denied | NotFound | Refused | Timeout | Exhausted | Unsupported | Other
```

- **`NetStream`:**
  - Credit flows both ways, and we grant receive credit only as bytes are read.
  - `shutdown` is a half-close.
  - `abort` sends Net `CLOSE`. Drop does a graceful half-close if the peer already finished, and aborts otherwise.
  - Frames are polled with a waker that wakes both halves, so `tokio::io::split` works.
- **`DatagramFlow`:** uses the native sideband when the transport has one, and the reliable tunnel otherwise. Its receive queue is bounded and drops the oldest datagram first.
- **Open limit:** opens in flight are capped at the server's `max_pending_opens`. Extra opens wait instead of failing.
- **CLI:** `yas forward` (tcp/udp/tls) and `yas socks` now run on this API, and the CLI's duplicate client is gone.

## 2. `yas server --net-only`
Also `YAS_NET_ONLY=1`, or `Config::net_only` for embedders.
- **HELLO:** it selects Core, Transfer and Net only. A client that *requires* any other family is refused with `UNSUPPORTED`. A request to any other family gets `UNSUPPORTED` at once.
- **Nothing else starts:** no compositor, processes, extension restore, font scan, relay catalogue or KV warm-up.
- **No client shutdown:** Core `SHUTDOWN` isn't offered or accepted.
- **Strict policy:** net-only implies `--allow-forward-strict`.

## 3. `--allow-forward-strict`
Also `YAS_ALLOW_FORWARD_STRICT=1`, or `Config::allow_forward_strict`. Only the listed patterns are reachable:
- loopback is allowed only if listed;
- an empty list, or one where no pattern parses, permits nothing;
- Unix sockets and Windows pipes are refused, since no `host:port` pattern can name them.

## Other changes
- **Connect and TLS timeouts** now answer `TIMEOUT` instead of `IO`. net.md's SOCKS table already listed `TIMEOUT`.
- **Address and CIDR rules on names:** before, a request for a *name* under an address or CIDR rule was denied before resolving, even though net.md says these rules match resolved addresses. Now the name is resolved and its addresses are checked.
- **Wire:** no wire layout, constant or baseline changes. `cargo xtask protocol --check` passes.

## Tests
- `crates/cli/tests/client_net.rs`, against real hosted servers:
  - 8 concurrent streams of more than 3 MiB each way on one session, with half-close;
  - abort;
  - UDP;
  - failure reasons (Denied, Refused, NotFound);
  - a strict server and a net-only server.
- **yas-server, net-only:** HELLO selects only the three families, a required other family is refused, every other family's first request gets `UNSUPPORTED`, unlisted loopback is denied and a listed target works.
- **yas-server, policy:** strict lists and address rules on names.
- **yas-client:** datagram queue and dispatch, and `net_failure`.
- **Manual:** I ran `yas forward` (tcp with a 20 MB transfer, and udp) and `yas socks` against a normal server and a net-only server.

## Checks
- **Passing:**
  - `cargo test -p yas-client`, `-p yas-cli --lib --test client_host --test client_net`;
  - `cargo test -p yas-server --lib`: 950 passed. The one failure, `leave_residue_reports_the_exit_after_its_grace…`, timed out under load and passes when run alone, both with and without this branch;
  - `cargo clippy -p yas-client -p yas-server --all-targets` and `-p yas-cli --lib --bins --test client_net` with `-D warnings`;
  - `cargo fmt --check`, `cargo xtask protocol --check`.
- **Skipped:** `cargo clippy -p yas-cli --all-targets` fails on `client_host.rs:1379` (`unused mut`). That's already on main and untouched here, and CI's `--workspace` clippy doesn't lint test targets. Prettier wasn't available in the sandbox, so I aligned the Markdown table by hand.

<sub>Opened from [an Ultimator session](https://ultimator.app/sessions/uuxygea7oj6klbon)</sub>
… the session (yas-run#100)

A Terminal view retires as soon as its terminal closes or exits. The client can still send `FRAME_ACK` (or `INPUT`/`MOUSE`/`WHEEL`/`WHEEL_AT`) for that view if it hasn't seen the retirement yet. `update_terminal_feedback` treated the unknown `view_id` as a protocol error, so the dispatch loop ended the **whole native session**:

```
native YAS session exit: reliable frame handler failed: class=Event family=16 kind=34 request_id=None
```

(family 16 / kind 34 = `yas.terminal` `FRAME_ACK`). Any other request in flight on that connection then fails with `write EPIPE` / `read ECONNRESET`.

`docs/design/yas.md` already says "Stale feedback is ignored", and Surfaces already tolerate this via `retired_views`. Now a missing view returns `None` and the event is dropped. Malformed payloads and out-of-window feedback on live views are still errors.

**Impact.** In Indent sandboxes, every short-lived shell command is a terminal that exits and gets closed, so parallel commands hit this race constantly:
- Over 7 days: about 280 failed commands across 235 sessions.
- All of them on Yas, starting when sandboxes moved to Yas.

**Repro.** An embedder's shell client runs 20 concurrent commands (300 output lines each) against a private server:

| server | failed / total |
|---|---|
| 0.3.0 | 14/200 |
| `main` (pre-0.5.0) | 5/200 |
| this change | 0/400 (×4 runs) |

Every failure in the stock runs matches the exit line above.

**Test.** `terminal_feedback_for_a_closed_view_does_not_end_the_session` acks a frame after `CLOSE`, then checks the session still answers a request. Without the fix it fails with the same `class=Event family=16 kind=34` exit.

Rebased onto current `main` (cherry-pick was clean). The test and repro above were run before the rebase; Rust CI covers the rebased tree.

Ship this together with the SDK PR that settles fire-and-forget Terminal requests. With this change alone, a stale `setFocus` comes back as an unhandled `NOT_FOUND` more often, and that exits Node embedders.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a114ae-0323-7e02-9ce8-16882d89ab51) [![View in Slack](https://assets.indent.com/slack-thread.svg)](https://indent-com.slack.com/archives/C0BS1AEBG20/p1791348309864339?thread_ts=1791348309.864339&cid=C0BS1AEBG20)
Tag `@indent` to continue the conversation here.
…kspace connection (yas-run#101)

Several requests in `YasNativeWorkspaceConnection` are fire-and-forget: `restartSession`, `killSession`, `focusSession`, `resizeSession`, the scroll request, and the focus refresh after `OPEN_VIEW`. They dropped the request promise with `void`. When one races its terminal's exit or close, the server answers `NOT_FOUND`; when the link drops, it rejects with `YasDisconnectedError`. Nothing handled either rejection, so a Node embedder hit an unhandled rejection and exited.

`onRequestFailure` (yas-run#94) lets an embedder observe these failures, but it doesn't handle the rejection itself.

Indent's sandbox client exits on unhandled rejections. Over 7 days, `YasResultError: YAS request failed with status 3` crashed it on 15 sandboxes.

`settleTerminalRequest` drops those two expected failures and `console.error`s anything else, matching the existing `.catch(console.error)` pattern in this file.

**Verified** with a shell client running 20 concurrent commands against a server with yas-run#100. Without this change, a stale `setFocus` (`focusSession` ← `recomputeSnapshot` ← `applyTerminalCatalog`) produced an unhandled `NOT_FOUND` about once per 400 commands and exited the process. With it: 1200/1200 OK and no unhandled rejections. Typecheck passes on current `main`.

No unit test: under the jsdom vitest config, a test that leaves the rejection unhandled still passes, so it can't pin this behavior.

Pairs with yas-run#100.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a114ae-0323-7e02-9ce8-16882d89ab51) [![View in Slack](https://assets.indent.com/slack-thread.svg)](https://indent-com.slack.com/archives/C0BS1AEBG20/p1791348309864339?thread_ts=1791348309.864339&cid=C0BS1AEBG20)
Tag `@indent` to continue the conversation here.
yas-run#102)

yas-run#101 broke `js/core` CI on `main` (and 0.5.1):
- **test:** the `setFocus` stubs in `YasNativeWorkspaceConnection.test.ts` were bare `vi.fn()`, which returns `undefined`, so `settleTerminalRequest` threw `Cannot read properties of undefined (reading 'catch')` and "promotes a new focused view ahead of an older blocked preview" failed. Real clients always return a promise; the stubs now resolve to `undefined`.
- **lint:** I'd formatted with a newer prettier than CI's 3.8.3, which collapsed the `mode:` union in `requestScroll`. Restored.

No runtime change: production `setFocus` always returns a promise, so 0.5.1 behaves as intended.

Checked locally: `js/core` vitest passes (86 files, 1529 tests), and `prettier --check .` with nixpkgs prettier 3.8.3 passes.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a114ae-0323-7e02-9ce8-16882d89ab51) [![View in Slack](https://assets.indent.com/slack-thread.svg)](https://indent-com.slack.com/archives/C0BS1AEBG20/p1791348309864339?thread_ts=1791348309.864339&cid=C0BS1AEBG20)
Tag `@indent` to continue the conversation here.
…#103)

The macOS `build-packages` job runs `cargo test --workspace --lib --bins --tests` with `RUSTFLAGS=-D warnings`, and `the_process_catalogue_can_be_watched` binds the taken stdin as `mut` even though `ByteSink::finish` takes `self` by value. That `unused_mut` failed the [v0.5.1 release run](https://github.com/yas-run/yas/actions/runs/37599619582/job/112727819906) and every `main` CI run since yas-run#97.

Linux CI misses it because `bin/clippy` runs `cargo clippy --workspace` without test targets, and the Linux test job doesn't deny warnings.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a117d3-9d08-7f12-9d17-101fec74979f)
Tag `@indent` to continue the conversation here.
`bin/clippy`'s workspace pass ran `cargo clippy --workspace`, which skips tests, benches and examples. So test-only warnings first surfaced in the macOS `build-packages` job (`cargo test ... --tests` under `RUSTFLAGS=-D warnings`), e.g. the `unused_mut` that broke the [v0.5.1 release](https://github.com/yas-run/yas/actions/runs/37599619582/job/112727819906).

The pass now uses `--all-targets` on Linux, where CI lint runs. Other hosts use `--lib --bins --tests`, because the compositor's Wayland probe examples only build on Linux.

Stacked on yas-run#103: this branch includes its commit, since `main` fails the stricter lint without it. Merge yas-run#103 first.

Verified locally on Linux: `./bin/lint --check` passes, the generated `yas-clippy` runs `cargo clippy --workspace --all-targets -- -D warnings`, and `cargo clippy -p yas-cli --tests -- -D warnings` on `main` reproduces the yas-run#103 error.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a117d3-9d08-7f12-9d17-101fec74979f)
Tag `@indent` to continue the conversation here.
…as-run#105)

`failed_opens_say_why` failed on the macOS `build-packages` job in [CI run 37679564666](https://github.com/yas-run/yas/actions/runs/37679564666/job/112991758235):

```
panicked at crates/cli/tests/client_net.rs:193:5:
  left: Refused
 right: Denied
```

The test binds `listed`, then gets `closed` from a second ephemeral bind, allowlists both, and expects `127.0.0.1:listed+1` to be Denied. When the OS hands out consecutive ephemeral ports, `closed == listed + 1`. That port is allowlisted with nothing listening, so the open is Refused. Linux randomizes the ports, so the collision rarely happens there.

The fix picks the first port after `listed` that is neither `closed` nor 0, and uses it for both Denied assertions. This changes only the test.

Not run locally: the sandbox kept killing the `yas-server` build (memory pressure). The macOS job on this PR is the check.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a11812-e3b6-751a-aeaf-c923b87ed786) [![View in Slack](https://assets.indent.com/slack-thread.svg)](https://indent-com.slack.com/archives/C0BS1AEBG20/p1791405252886369?thread_ts=1791405252.886369&cid=C0BS1AEBG20)
Tag `@indent` to continue the conversation here.
…agePort delivery (yas-run#106)

Fixes the Windows failure in the v0.5.1 release run ([job](https://github.com/yas-run/yas/actions/runs/37717122931/job/113116087786)):

```
FAIL src/sw/__tests__/shimSocket.test.ts > relayed WebSocket liveness > bounds an unterminated handshake header
shimSocket.test.ts:250  expect(relayMessages.some(relayCloseRequest)).toBe(true)  → false
```

`#died` posts `{ yasClose }` to the relay port and then dispatches `error`/`close`. The test saw both events, so the shim had already sent the close request. What it hadn't seen yet was the message arriving on the other side of the `MessageChannel`. That delivery isn't ordered against timers, and the two `setTimeout(0)` waits cover three async hops: payload delivery, `#fail`'s timer, and the close request delivery. On a loaded Windows runner the last hop landed after the assertion.

Both assertions on the relay close request (this test and "reports a close when the handshake stalls", which races the same way under fake timers) now use `vi.waitFor`. The shim is unchanged.

Verification (locally, Linux):
- `vitest run src/sw/__tests__/shimSocket.test.ts`: 13/13 pass; `prettier --check` clean.
- I couldn't reproduce the race on Linux under load (1200 runs). To show the fix covers it, I temporarily delayed relay-side delivery by 5 ms. With that delay, the old assertion fails with the same `expected false to be true`, and the new one passes.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a11884-6a46-7119-a0bd-0095f1623f62) [![View in Slack](https://assets.indent.com/slack-thread.svg)](https://indent-com.slack.com/archives/C0BNY3MJ9U1/p1791412662058559?thread_ts=1791412662.058559&cid=C0BNY3MJ9U1)
Tag `@indent` to continue the conversation here.
…se releases (yas-run#107)

## Washed-out colors (reproduced + fixed)

The compositor answered `wp_color_management_surface_feedback.get_preferred` and the output's `get_image_description` with BT.2020 PQ. Chromium renders for what it is told, then leaves the surface **untagged**, and an untagged surface is sRGB. PQ-encoded 203-nit white (0.58) was therefore shown as 148, primaries as e.g. 136,83,56 for red. Pierre's HDR hunch was right: the compositor was advertising HDR to apps that then never tagged it.

Fix: the preferred/output description is now sRGB (`ImageDescription::PREFERRED`), and `get_information` describes what is advertised. HDR clients still create and tag their own PQ/HLG/scRGB descriptions.

Measured with Chromium (Wayland, lavapipe) on a bars page, `yas surface capture` and an H.264 `surface record` frame:

| bar | before | after |
|---|---|---|
| white | 148,148,148 | 255,255,255 |
| red | 136,83,56 | 255,0,0 |
| green | 119,146,89 | 0,255,0 |
| blue | 74,50,145 | 0,0,255 |
| gray 128 | 109 | 128 |

## Mouse clicks that sometimes do nothing (cause not reproduced; lost-release paths fixed)

I could not reproduce lost clicks: 40 sequential CLI clicks, 150 randomly timed synthetic and real browser clicks through the YAS UI, and alternating clicks across two windows all landed; click coordinates were exact. What the code does allow is a lost `mouseup` (swallowed by the browser or a focus change mid-press), which leaves the app holding the button so the next press is ignored. Changes:

- `YasSurfaceCanvas`: reconcile with `MouseEvent.buttons` on motion, release a held button before pressing it again, release held mouse buttons on window blur (touch gestures are tracked separately and untouched).
- Compositor: a press on a button clients still hold is preceded by the lost release.

If clicks are still lost after this, I need to know the viewer (web UI, desktop app, iPhone...) and the app.

## Tests
- `cargo test -p yas-compositor` (new `tests/pointer_button_recovery.rs`; `color_management` now asserts the preferred description is sRGB), `cargo test -p yas-server --lib`, clippy `-D warnings` and `cargo fmt --check` on the compositor.
- `vitest` js/core (new tests for the three release paths), `tsc --noEmit`.

Not tested: a real HDR display or Safari/macOS viewer (the sandbox is SDR with lavapipe); an HDR app (mpv) still tags itself but I did not run one.

<sub>Opened from [an Ultimator session](https://ultimator.app/sessions/rrgvfq6rhvvgtmux)</sub>
…-run#108)

Forge P3 (design m5weenadk7pdbxgx §10). This adds YAS's workflows in Forge's CI format. The GitHub workflows stay as they are and keep running until Forge's P4.

## What it adds (`.ultimator/`)

### `actions/nix`
Replaces `DeterminateSystems/nix-installer-action` and `magic-nix-cache-action`, which are Marketplace actions Forge doesn't run.
- It keeps a machine's own Nix (crab).
- Otherwise it installs Nix with Determinate's installer and turns on flakes.
- There's no binary cache beyond cache.nixos.org, so a fresh sandbox's first build is cold.

### `actions/packages` and `actions/windows-x64`
GitHub's reusable `_build-packages.yml` and `_build-windows.yml` become composite actions: Forge has no reusable workflows.

Windows x86_64 builds on crab-win (Windows 11 ARM64) under x64 emulation:
- It uses the `stable-x86_64-pc-windows-msvc` toolchain (`--force-non-host`) and checks that `yas.exe`'s PE machine is x86_64.
- crab-win needs Visual Studio's x64/x86 build tools; the job says so if they're missing.
- The job installs wasm-pack, pnpm 10 and bun 1.3.13 itself.

### `workflows/ci.yml`
The same jobs as GitHub's:
- Nix syntax, lint and publication plan, package-crates, tests, a 60 s fuzz;
- e2e, with the Playwright report kept as an artifact;
- coverage, with its summary in the job summary (there's no sticky PR comment);
- packages on `sandbox` (linux-x86_64), `arm64-ci` (linux-aarch64) and `crab` (macos-aarch64, with the macOS graph tests);
- Windows on `crab-win`.

### `workflows/release.yml`
On `v*` tags.
- **Signature:** it verifies the tag's SSH signature with `git verify-tag` against `YAS_TAG_SIGNERS`, an allowed_signers line. That replaces GitHub's "verified identity" check. Without it, or for an unsigned tag, the release stops.
  - `YAS_TAG_SIGNERS` is a sealed secret only because Forge has no repository variables yet, and admins alone set secrets.
- **Gates and builds:** lint, tests, e2e, coverage, packages, Windows, extensions, and three one-hour fuzz campaigns.
- **Release artifact:** `release-<tag>` with the stable asset names and `SHA256SUMS`, kept 400 days. P4 gives releases a home on Forge.
- **Publishing:**
  - crates.io with `CARGO_REGISTRY_TOKEN`;
  - npm packages and binary packages with a granular `NPM_TOKEN`, written to the job's npmrc.
  - `--provenance` is dropped: it needs GitHub's OIDC.

### `workflows/deploy-website.yml`
`nix run .#deploy-website` with `FLY_API_TOKEN`, on the same paths as GitHub's workflow.

## Evidence
`ultimator ci check .ultimator`, built from xmit-dev/ultimator `forge/p3-store`, reads and plans every file with no errors:
- `ci.yml`: 11 jobs;
- `release.yml`: 17 jobs;
- `deploy-website.yml`: 1 job;
- three composite actions.

The jobs run on `sandbox`, `arm64-ci`, `crab` and `crab-win`.

**Not run yet.** That needs Pierre's steps:
- YAS hosted on Forge, with the `ci` module on;
- executors for those labels;
- the secrets:
  - on `refs/tags/v*`: `CARGO_REGISTRY_TOKEN`, `NPM_TOKEN` and `YAS_TAG_SIGNERS`;
  - on `main`: `FLY_API_TOKEN`;
- protections on `main` and `refs/tags/v*`, so those runs are trusted.
`run_terminal_watch` responded to every catalogue revision bump with `Phase::Reset`, followed by a full `send_terminal_snapshot`: BEGIN, then one `SNAPSHOT_RECORDS` event per terminal, then END. A single create, title/cwd change, exit or close therefore resent the whole catalogue to every terminal watcher. Each record was its own event, and clients ACK each event.

The cost grows with the catalogue size. On a production box running hundreds of agent subagents, the catalogue held 1,033 terminals (9 running and 1,024 exited, retained under `DEFAULT_MAX_EXITED`), at about 817 B per record. That made every terminal event about 0.8 MB and about 1,036 frames per watching connection. A 5s sample on the client counted 14 RESET, 14 BEGIN, 14,003 RECORDS, 14 END and 0 DELTA, and the client's event loop saturated.

The terminal watch now does what `run_client_watch` already does. `terminal_delta_event` diffs the previous and current `TerminalCatalogue` by handle and builds one `Phase::Delta` event:
- `Add` for new handles
- `Replace` for changed records
- `Remove` (`RemovedTerminal`) for handles that are gone

Unchanged records are not sent. If the delta doesn't fit the event limit, it falls back to RESET plus snapshot through `state_delta_reset`, as the client watch does. Because the `watch` channel coalesces revisions, a delta can span several revisions (`from_revision` → `to_revision`).

Consumers already handle terminal deltas: `@yas-run/core`'s `YasTerminalCatalog.apply` (`YAS_STATE_DELTA` with ADD/REPLACE/PATCH/REMOVE) and the CLI's `watch_terminal_exit`.

Tests:
- New `terminal_catalogue_revision_is_one_delta_with_only_changed_records`.
- `terminal_cross_session_close_discards_frames_before_removal_state` now accepts the removal as a DELTA `Remove` for the closed handle, as well as the old reset path. It still asserts that no ADD/REPLACE of the closed terminal arrives and that no frame follows.
- `cargo check -p yas-server --tests` and `cargo clippy -p yas-server --lib --tests -- -D warnings` pass, and `rustfmt --check` is clean.
- I couldn't run the test binary locally: the sandbox SIGTERMs `rustc` linking the `yas-server` lib test. CI needs to confirm both tests.

A follow-up could send `TerminalPatch` when only extensions change, instead of a full `Replace`.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a12190-0c37-766e-8546-68e8eda1db1b) [![View in Slack](https://assets.indent.com/slack-thread.svg)](https://indent-com.slack.com/archives/C09SPJV8485/p1791564208588529?thread_ts=1791564208.588529&cid=C09SPJV8485)
Tag `@indent` to continue the conversation here.
…dle) (yas-run#110)

## Problem
On Windows, any durable extension deploy fails: `yas ext run --persist …` (and Ultimator installing the muster extension on a machine's YAS server) ends with `YAS request 0x0043/0x0004 failed with Internal: no detail`.

## Cause
`ObjectRead::sync` (the durability barrier `DEPLOY` runs before committing a persistent definition) opened the object with `File::open` (read-only) and called `sync_all`. `FlushFileBuffers` needs write access, so on Windows this returns `ERROR_ACCESS_DENIED` (os error 5). The error surfaces as a storage failure → `EXT_STATUS_OTHER` → `Internal` with no detail. Non-persistent runs skip the barrier, which is why they work.

Reproduced on Windows 11 ARM64 with the ultimator 0.1.0 embedded YAS: `yas ext run --detach x obj.wasm` works; `--persist` fails with the error above. A standalone check shows `File::open(p).sync_all()` → `Err(PermissionDenied, "Access is denied.")` while `OpenOptions::new().write(true).open(p).sync_all()` → `Ok`.

## Change
Open the object for write before `sync_all` (as `write_lru_metadata_atomic` already does). The directory fsyncs stay `#[cfg(unix)]`. Adds `durability_barrier_syncs_a_committed_object`.

## Verified / Not verified
- Verified: root cause reproduced against the live Windows server; the open-mode difference confirmed with a standalone Rust program on the same machine.
- Not verified: `cargo test -p yas-server` and a rebuilt server (the Windows ARM64 box has no clang for cc-rs, so the crate would not build there); CI should run the new test. Not run on Linux/macOS (the change is a no-op there beyond opening for write).
- Follow-up worth doing separately: `native_status_failure`/`catalog_status` drop the storage error text, so this showed up as "no detail".

<sub>Opened from [an Ultimator session](https://ultimator.app/sessions/drb3itjyr2unhdj4)</sub>
… and missing-program status (yas-run#59)

Stacked on yas-run#57. Replaces #33, which was opened from indent-com/yas.

The commit fixes three bugs. They share hunks, so they stay in one commit.

- **Restart:** `yas terminal restart` always answered Unsupported from the CLI, because the launch record was stored per connection. It now lives on the terminal, so a restart works from any client.
- **Server env flags:** `--export-sock` and `--inject-path` had no effect on terminals. They now apply. Terminals also no longer inherit a `YAS_SOCK` that points at a different server.
- **Missing program:** starting a program that doesn't exist answered ResourceExhausted. It now answers NOT_FOUND, and the CLI prints `PROGRAM: command not found`.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a0db15-330c-717e-9ec3-032c80770b1c)
Tag `@indent` to continue the conversation here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…n name conflicts (yas-run#61)

Stacked on yas-run#59. Replaces #34, which was opened from indent-com/yas.

- `yas ext run` now prints the output of an attempt even when it finishes before the CLI starts following it.
- A trapped attempt prints its error. Before, the only sign was exit code 1.
- A name held by a retained transient gets a clear message instead of `Conflict: no detail`.

[![View in Indent](https://assets.indent.com/view-in-indent.svg)](https://app.indent.com/c/01a0db15-330c-717e-9ec3-032c80770b1c)
Tag `@indent` to continue the conversation here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…-run#111)

YAS's CI workflows in TypeScript (Forge P3, decision 18; needs xmit-dev/ultimator#811, the TS switch, which removed the YAML front end).

- `.ultimator/workflows/ci.ts`: the `ci` workflow — push to main plus pull requests; 11 suite jobs plus the `ok` gate (`ci/ok`, the check to require: runs `always` on `ULTIMATOR_NEEDS_RESULT`); packages matrix as three jobs (sandbox, arm64-ci, crab); Windows x86_64 on crab-win with a compiler cache.
- `.ultimator/workflows/release.ts`: the `release` workflow — tag `v*`; `verify-tag` checks the SSH signature against the declared `YAS_TAG_SIGNERS` secret; lint/test/e2e/coverage, three packages jobs, Windows, extensions, three protocol-fuzz jobs; `release` gathers everything into `release-<tag>`; `publish-crates`, `publish-npm` and `publish-bin-npm` declare their secrets.
- `.ultimator/workflows/deploy-website.ts`: deploys yas.run on pushes to main touching the site, declaring `FLY_API_TOKEN`.
- `.ultimator/lib/steps.ts`: the shared `sh`/`nix`/`packages`/`windowsX64` builders (GitHub's reusable workflows and composite actions, as functions).
- The YAML files and composite actions are gone. `ultimator ci init` wrote `.ultimator/ultimator-ci.d.ts` and `tsconfig.json` (committed).

## Verified / Not verified
- `ultimator ci check` (from ultimator#811's CLI): `ci` plans 12 jobs (11 suites + `ok`); `release` plans 17 on a tag; `deploy-website` 1 job.
- `tsc -p .ultimator`: pass.
- YAS's own checks (`bin/tests`, lint): not run here — CI says.
- Production executors: not verified — needs Pierre's production steps.

<sub>Opened from [an Ultimator session](https://ultimator.app/sessions/7vbaenqdftsk45r3)</sub>
@indent
indent Bot force-pushed the ci-diag/windows-residue branch from 02e24c0 to bae4e30 Compare October 15, 2026 01:13
@indent

indent Bot commented Oct 15, 2026

Copy link
Copy Markdown

Diagnosis done: Windows PowerShell 5.1 cold start (~23 s on the 20261007 image) outran the 20 s read timeout. Fix is yas-run#114.

@indent indent Bot closed this Oct 15, 2026
@indent
indent Bot deleted the ci-diag/windows-residue branch October 15, 2026 01:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants