Skip to content

Make control mode, queries, and workspaces deterministic - #18

Merged
tony merged 130 commits into
masterfrom
improvements-00
Aug 30, 2026
Merged

Make control mode, queries, and workspaces deterministic#18
tony merged 130 commits into
masterfrom
improvements-00

Conversation

@tony

@tony tony commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Bounded execution: Centralize process and control-mode work behind bounded admission, output, cancellation, and cleanup.
  • Correct correlation: Frame control replies strictly and preserve command identity across notifications, failures, and close races.
  • Typed reads: Close the query JSON grammar, validate field domains, and use targeted entity reads and immutable snapshots.
  • Explicit workspace failures: Validate before effects and return immutable partial-state evidence when later application fails.
  • Composable operations: Add chain sequencing and wait-channel abstractions while bounding MCP jobs and waits.

Changes by area

  • Core runtime: Harden control mode, process ownership, snapshots, queries, options, and request materialization.
  • Workspace: Make YAML validation, plans, readiness, and nontransactional partial results explicit.
  • Distribution: Extend package-consumer, trimming, AOT, integration, parity, and documentation gates.

Breaking changes

  • JobStore uses asynchronous disposal.
  • NewWindowRequest requires its owning session.
  • Workspace collections and command values become immutable.
  • Invalid query JSON and workspace YAML previously accepted by permissive parsers now fail closed.

Test plan

  • Net patch and changelog pass git diff --check.
  • .NET 8, .NET 10, NativeAOT, package-consumer, examples, integration, compatibility, macOS, and CodeQL gates pass.

tony added 30 commits August 29, 2026 04:30
why: TmuxCommand retained caller-owned lists and compared those lists by
identity, so a command could change after construction and equal values did
not compare equal. NUL also cannot cross the tmux command boundary.

what:
- Copy and validate command arguments at construction
- Compare command names, arguments, and server generations by value
- Cover mutation, invalid tokens, and value equality with red-first tests
why: Tmux emits one control block per parsed command, not per input line, and
interleaves unflagged hook blocks. Raw lines, aliases, and semicolon sequences
could therefore move one caller's output to another caller.

what:
- Accept immutable TmuxCommand values and validate their server generation
- Render literal argv and collect flagged blocks through a private parser fence
- Preserve alignment after cancellation and keep typed command diagnostics
- Update consumers, examples, public contracts, and real-tmux coverage
why: A control client could deadlock startup on an unread stderr pipe or allocate an unbounded stdout line.

what:
- drain stderr continuously while retaining a bounded diagnostic tail
- parse stdout with a bounded UTF-8 line reader
- verify the real pipe-fill regression and bounded helpers
why: Disposal dequeued pending work before its active write finished, turning a valid reply into an orphaned protocol block.

what:
- let the protocol pump remain the terminal owner of queued requests
- reproduce the send/dispose interleaving with the existing stalled-write fake
why: Unanswered calls, rendered requests, and accumulated replies could retain memory without a transport-level ceiling.

what:
- reject excess pending work and oversized framed requests before dispatch
- bound every control block and aggregate reply while discarding canceled payloads
- preserve capacity ownership through cancellation and document the bounded contract
why: Serial per-phase timeouts could multiply the nominal cleanup window and lose late task faults.

what:
- reserve grace time, then await forced exit, output, stderr, and writer cleanup concurrently under one deadline
- preserve boundary faults and observe operations that complete after timeout
- extract cleanup orchestration and verify timing with an injected manual clock
why: Reserved guards outside a block were exposed as notifications, hiding a corrupted control stream.

what:
- parse every well-formed guard before notifications and fail on malformed reserved names or orphaned terminators
- preserve guard-looking command output inside its active block
- cover the boundary with fake-process cases and real tmux 3.2a and 3.7c integrations
why: A restart between discovery and attach could bind the control client to a replacement daemon, and command aliases could forge a normal generation query.

what:
- compare pid and start time through an alias-resistant parser condition
- report a stale generation without inventing an unknown replacement identity
- cover forged restarts on tmux 3.2a and current
why: Exact profiles silently disabled valid behavior on stable tmux releases that were not listed verbatim.

what:
- Replace cumulative exact profiles with named support intervals
- Keep prerelease, development, next, and below-minimum states unknown
- Migrate production gates and real-server proofs to strict lookups
why: The compatibility contract and validator still described closed exact profiles after stable interval selection replaced them.

what:
- Document stable tmux 3.2a-and-newer support and unknown build kinds
- Record the interval-model decision and the separate psmux boundary
- Verify every capability interval against its real-server delta ledger
why: The writer silently changed forged enum values, emitted documents its reader rejected, and rejected valid supplementary Unicode. The reader also converted malformed values into valid defaults and leaked structural exceptions.

what:
- Validate document identity, enum values, regex metadata, Unicode scalars, and encoded size while writing
- Reject unknown quantifiers, null text, malformed shapes, and invalid tightened limits while reading
- Add red-first coverage for every corrected boundary
why: The production port emitted a private wire shape that disagreed with the accepted architecture decision, retained goldens, and schema shipped in every package.

what:
- Restore the libtmux-query identifier and canonical field, operator, and constant encodings
- Read canonical comparison and tagged-constant nodes back into the production AST
- Bind the implementation to an external byte-for-byte golden
why: Regex translation and JSON accepted culture-dependent and execution-only modes even though the v1 schema and retained proof require invariant, portable semantics.

what:
- Give query regex semantics one internal owner shared by core translation and JSON
- Require CultureInvariant and reject unsupported option bits on both wire directions
- Preserve canonical JSON bytes with the selected encoder and retained regex golden
why: Deserialization ignored unknown and duplicate members despite the v1 schema forbidding them, and its parser depth rejected documents the writer allowed.

what:
- Validate exact envelope, node, and constant member sets
- Reject duplicate properties instead of accepting last-value ambiguity
- Separate parser nesting from the declared logical query depth
why: The production catalog lost field value kinds, allowing documents whose fields, targets, constants, and predicates contradicted the shipped schema and changed meaning across consumers.

what:
- Restore value kinds to the generated field catalog
- Validate every translated, compiled, serialized, and deserialized document in the core
- Interpret schema-valid Boolean field predicates and reject type-changing projections
why: Generated self-roundtrips stayed green when the accepted wire format
changed, so they could not defend the versioned contract.

what:
- Embed the retained version-one goldens in the unit test assembly
- Round-trip every accepted artifact byte for byte
- Keep translated-document round trips as separate coverage
why: One file mixed closed-shape validation, wire writing, and hostile-input
reading, which obscured the package's trust boundaries.

what:
- Move the v1 reader into its own internal file
- Move closed-wire validation rules into their own internal file
- Keep the writer API and canonical bytes unchanged
why: The package README showed an unsupported schema name and flattened AST,
so copying it produced a document the reader rejects.

what:
- Replace the example with the canonical nested v1 wire shape
- Name every resource ceiling enforced while reading
why: The packed consumer restored only after the solution had warmed its
external dependencies, so CI could hide an incomplete downstream graph.

what:
- Map local LibTmux packages separately from public dependencies
- Restore the CI consumer through a fresh NuGet cache
- Make workflow validation reject a shared consumer cache
why: The optional package declared NativeAOT compatibility but no packed or
native consumer referenced it, so that claim could regress invisibly.

what:
- Round-trip Query JSON through both downstream smoke executables
- Reference the optional package from packed and NativeAOT graphs
- Keep runtime-specific lock restores stable and assert their output
why: Raw-line rewriting corrupted valid quoted pane commands, while reflection binding silently discarded unsupported configuration.

what:
- Parse one bounded YAML tree into defensively copied workspace values
- Support scalar and ordered pane commands without changing their text
- Send each command with one Enter and prove the regression against real tmux
why: Window creation ignored the first pane directory, and duplicate focus flags selected the first entry instead of tmuxp’s last entry.

what:
- Create each window in its first pane directory when one is specified
- Let the last focused window and pane win
- Cover both behaviors against current tmux and tmux 3.2a
why: session_windows and window_panes are numeric tmux values in row projections but relations on materialized entities.

what:
- track scalar value kind independently from relation eligibility
- restore numeric count comparisons without opening quantifiers
- align the v1 JSON schema and regression coverage
why: the v1 schema advertised three operators that every reader and writer rejects.

what:
- remove unsupported ignore-case prefix, suffix, and contains tokens
- keep the schema aligned with the closed v1 wire vocabulary
why: culture-sensitive string overloads and regex timeouts were translated into narrower predicates without warning.

what:
- require explicit ordinal semantics for StartsWith and EndsWith
- retain the ordinal one-argument Contains overload
- reject Regex.IsMatch overloads whose timeout cannot cross the wire
- update executable examples and parity coverage
why: NativeAOT removed a query-only property while the public API suppressed the warning and the smoke test never evaluated a document.

what:
- annotate every reflective evaluation entry point
- preserve an AOT-only row explicitly and execute a compiled query
- bind the warning contract and native output with regression tests
- document the trimming requirement
why: the Workspace package exposed public types without the analyzer and baseline that protect the other packable libraries.

what:
- baseline all Workspace declarations on both target frameworks
- enable the Roslyn public API analyzer
- fail if any packable library lacks analyzer-backed baselines
- replace the stale reflection-based AOT explanation
why: package closure exercised core and Query.Json but could not detect a broken Workspace dependency or asset.

what:
- restore Workspace beside the other package references
- parse a real workspace through the packed assembly
- require the result on both target frameworks and the tmux support floor
why: the package readme promised inspectable partial results even though non-layout tmux failures throw after potentially creating part of a session.

what:
- distinguish layout diagnostics from thrown build failures
- state that builds are not transactional
- document the closed YAML subset, input bound, and relative-path behavior
why: Component 09 only compiled its predicate, so the parity gate could pass without proving local filtering.

what:
- Compile the document for the live Session entity.
- Invoke the predicate as part of the parity proof.
tony added 20 commits August 30, 2026 06:13
why: Startup_rejects_a_server_restart_between_discovery_and_attach failed
in two runs of three, in about a tenth of a second. Its wrapper script
runs kill-server and then new-session on the same socket with nothing in
between. kill-server returns before the daemon has finished, and the
daemon unlinks the socket as it exits, so the successor can be handed a
socket the old server then removes.

This repository already knows that hazard: RawTmuxTestContext offers
WaitForSettledAsync for it, and ChainGenerationTests says in a comment
why the old server has to be gone first. The wrapper is a shell script,
so it waits the same way with a bounded poll.

Four consecutive suite runs, where it previously failed in two of three.

what:
- Wait for the old server to stop answering before starting the successor
why: Ten files held one lazily built property each, between sixteen and
twenty-two lines, and two of them per entity had names that differ by a
suffix and mean unrelated things: X.Environment.cs resolves a handle from
the process environment, X.EnvironmentOperations.cs reaches the tmux
environment table. Looking for one found the other.

An entity's option, hook and environment tables are one job -- how it
reaches what tmux scopes to it -- so they belong together, and the file
that resolves a handle from the environment can then say so in its name.

The six-line files named after each type stay. They carry the type's
documentation comment, which has to live on exactly one declaration.

what:
- Merge the option, hook and environment-table partials into X.Scopes.cs
  for each of the four entities, ten files into four
- Name X.Environment.cs for what it holds, X.FromEnvironment.cs
why: Four different integration tests failed at the ten-second mark while
the suite ran beside a build -- a workspace build, an MCP job, a control
mode restart, a paste-buffer parity check -- each looking like a distinct
bug. They are one: twenty-four polls hard-coded ten seconds, and every one
waits for something tmux is about to do rather than asserting a timeout.

Fixing them one at a time is what let the fourth appear after the third
was closed, and a fifth would have followed. Naming the budget once puts
the class behind a single value.

Five consecutive suite runs, and the run time did not move: these polls
return as soon as the state arrives, so a longer ceiling costs nothing.

what:
- Add TestBudget.Settle and read every ten-second poll deadline from it
why: An independent review called the marker scan redundant, because the
SHA-256 already proves the bytes are the audited binary and
ValidateExpectedBinarySha256 refuses any other hash. That is right about
the public path, so the scan cannot fail through it. It is defence in
depth on a security-sensitive path reached by an internal method, so it
stays.

What was wrong is where the markers came from. "66cf613" and "2026-08-18"
were byte literals in the verifier, a third spelling of a build identity
already held twice in PsmuxCompatibility, with nothing tying them
together. Moving the pin would have left the verifier looking for the
previous build.

The analyzer catches a changed public constant, because its value is
recorded in the baseline. It cannot see that the short commit is a prefix
of the long one, which is the mistake a maintainer makes while updating
both.

what:
- Name the short commit and the build date once, and compose the banner
  from them so the three cannot disagree
- Scan for the markers the pin names rather than for literals
- Cover the short commit and date agreeing with the commit and the banner
why: The audit behind "ConfigureAwait(false) on every await" excluded
await using by construction, so it never looked at the one form that can
carry it and be missed. An independent review counted 303 of 304 in
LibTmux; across every shipped project it was eight sites.

TmuxProcessTransport already writes await using (process.ConfigureAwait
(false)), so the pattern was established and the rest simply did not
follow it. A using declaration cannot take the call inline, so the
disposable is named first and configured on the next line.

The three in the MCP tool's entry point stay as they are: a console Main
has no context to return to, and the comment says so, so the next sweep
does not read them as an omission.

what:
- Configure the await on the five library await using declarations
- Say why the entry point's three do not
why: The commit that pinned every command built from an entity enumerated
the overloads taking a Pane, Window or Session and stopped there. An
option or hook table is reached through one of those entities and carries
its identifier as the same plain text, so nine more builders in
TmuxChaining.Options.cs and TmuxChaining.Hooks.cs left RequiredGeneration
null. A chain of them reached the unguarded dispatcher and set or read
against whatever the replacement server gave that identifier to.

The entity methods were never affected: TmuxOptions.SetAsync and its
siblings dispatch through the entity dispatcher, which validates the
generation on every command. Only the batching surface was open.

The twelve builders still carrying no generation take either no receiver
or a Server, and none of them names an identifier this library read from
a handle, so there is nothing for a restart to reassign. Server.Chain now
says that rather than "built from an entity", which did not distinguish
them.

what:
- Carry the owning generation on TmuxOptions and TmuxHooks
- Name it in all nine option and hook command builders
- Say what actually decides whether a chained command needs a generation
- Extend the restart test to an option and a hook command
why: The previous sweep asked which tmux commands expand an argument and
answered for titles, names of sessions and windows, start directories and
if-shell. It missed the arguments that name a thing rather than describe
it. Confirmed on a live server: set-option '@myopt#{session_name}' hello
stores the option as @myoptprobe3, and new-session -n '#{session_id}-w'
names the window from the expansion.

The option case is the sharp one. A caller building a name from data sets
one option and reads another as soon as the surrounding session changes,
with nothing to see.

cmd-server-access.c expands its user argument the same way.

what:
- Document the expansion on the option, hook and first-window names, and
  on the users a server-access request allows or denies
- Drop the two assertions that could not fail: the banner is interpolated
  from the short commit and the date, so it agrees with them by
  construction, and only the prefix check does work
why: tmux 3.2a can terminate its server while expanding selected format
callbacks for a missing target. Direct point lookups therefore made an
ordinary stale handle destructive.

what:
- List and match records before formatting a missing 3.2a target
- Preserve session context while resolving linked windows and panes
- Record and test the capability boundary in the parity ledger
why: The checked-in script lock retained obsolete relative cutoff
options that current uv no longer reproduces.

what:
- Remove the inert cutoff options from the lock
why: Fixed two-second waits expired under the constrained CI lane even
when the workspace was still progressing normally.

what:
- Use the shared readiness and settle budgets
Why: macOS tmux may reject an arbitrary SHELL environment value and start its platform default, so the readiness fixture waited for a test shell that never ran.

What: Point the fixture’s private tmux configuration at the executable explicitly while preserving its child environment.
why: Script-backed shells expose platform-dependent process names, so the macOS readiness fixture never matched its configured shell.

what:
- Drive startup output through a private /bin/sh profile
- Pin the test shell, home, profile, and tmux configuration
why: macOS runs /bin/sh as a bash process, so a fixture configured as sh
cannot satisfy the readiness check's exact process-name match.

what:
- Run the startup fixture with an explicit /bin/bash default shell
- Load its receiver from a private Bash login profile and BASH_ENV
why: tmux uses the channel signal itself to withdraw a waiter, so a real
signal racing disposal cannot be attributed to either source.

what:
- define Signalled as observation before withdrawal rather than proof that
  no signal arrived
- report an unattributable MCP timeout without losing the pending signal
- reproduce the disposal race and the next caller's outcome deterministically
why: Cancelling a job watcher stopped only the local client while tmux kept its wait registration, and convenience-created write tools exposed no way to dispose the store and activity hub they owned.

what:
- observe job channels through TmuxWaitChannel and withdraw them on shutdown
- make WriteTools dispose only resources created by McpTools.Writing
- prove retained signals, caller ownership, idempotent shutdown, and live tmux cleanup
why: Concurrent disposal callers returned while the first channel
withdrawal was still in flight.

what:
- cache and share asynchronous waiter cleanup
- gate repeat disposal against a blocked channel signal
why: A completed transport failure did not prove that tmux removed its
server-side wait registration.

what:
- withdraw unless the wait completed successfully
- retain the original fault after proving the channel signal occurred
why: Signalling a wait that never registered seeds a pending channel event
for the next caller instead of withdrawing anything.

what:
- skip withdrawal for command and not-dispatched failures
- retain conservative cleanup for canceled and uncertain waits
- prove next-caller state and the existing fault policy
@tony
tony force-pushed the improvements-00 branch from 8709170 to b8077ff Compare August 30, 2026 13:50
tony added 7 commits August 30, 2026 08:57
why: The public control-client flag and query wire contract read a key that snapshot hydration never populated.

what:
- Use client_control_mode from hydration through query serialization.
- Cover a real attached control client and the translated wire name.
why: Nontransactional workspace failures left callers without exact handles for the tmux state already created.

what:
- Wrap application failures with a typed partial result.
- Preserve the original failure and dispatch certainty.
- Record windows before later configuration can fail.
why: SendTextAsync submits its text by default, so a following EnterAsync teaches and tests an extra blank command.

what:
- Remove redundant EnterAsync calls from examples and package smoke code.
- Resynchronize the published snippets.
why: Publishing an executable at its final path while it is still writable can make a concurrent exec fail with ETXTBSY.

what:
- Write and permission wrapper candidates before an atomic rename.
- Make an immediate execution failure visible instead of retrying it.
@tony
tony merged commit fd4cb1f into master Aug 30, 2026
23 checks passed
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.

2 participants