Skip to content

feat: conditional controller ownership for terminal attachments - #84

Open
jiashuoz wants to merge 50 commits into
mainfrom
feat/controller-ownership
Open

feat: conditional controller ownership for terminal attachments#84
jiashuoz wants to merge 50 commits into
mainfrom
feat/controller-ownership

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Sep 10, 2026

Copy link
Copy Markdown
Member

Conditional controller ownership for terminal attachments

At most one attached client may type into a session. Everyone else watches:
they get the screen and every byte of output, and nothing they send is
executed — not stdin, not a resize, not the bytes a terminal writes back in
answer to a query. Control is a lease on the session row (a monotonic
controller_generation, an opaque per-attach holder, an expiry), the
generation is the authority, and the fence is at the pty, in the sandbox,
because the plane is not where input executes.

What a person sees

rainier attach claims control when it is free and watches when it is not —
zero-click on one laptop, honest on two devices. Ctrl-\ takes control;
--view never claims; --take claims once. A displaced device prints
[another device took control; press Ctrl-\ to take it back] and stops
typing. A reconnect presents the generation it held, so it resumes what it had
and comes back a viewer, saying so, when somebody took it.

Compatibility

The change reaches three separately deployed parties — client, plane, sandbox
— and all four pairings are supported and tested in both directions. A client
that negotiates nothing gets exactly today's message set; a new plane stamps
every frame it forwards, so an unstamped frame reaching a sandbox means an
older plane and nothing else. Generations travel as decimal strings
everywhere, because a uint64 past 2^53 is silently wrong as a JSON number in
a browser. The matrix is in
docs/terminal-controller-ownership.md.

Fourth review — dispositions

An independent Opus review of 42475de found the state machine correct (store
CAS, single-step advance/displaceTo, handoff serialisation, pty fence)
and one liveness bug, three should-fixes and three nits in the layer around
it. The instruction for this round was to remove the class, not the
instance.

# Finding Disposition Where Regression test (fails without the fix)
1 BLOCKER — one peer that stops reading freezes every handoff; no write in displace is bounded Fixed. displace is a bounded fan-out: peers in parallel, every peer-facing step under Plane.step (one ack timeout). install bounds its runner write; ClientStream gets a write deadline so a wedged client is closed rather than held. The corrective install in the claim answer moved out from under the announce hold. attachplane/ownership.go, plane.go, stream.go TestAStuckPeerDoesNotStallAClaim, TestAStuckPeerDoesNotStallANewControllerAttach, TestAStalledExControllerIsFencedEvenThoughItsNoticeCouldNotBeDelivered, TestOneStalledPeerDoesNotHideAnothersDisplacement, TestTheFanOutReachesEveryPeerAtOnce, TestABindingWriteThatNeverLandsDoesNotHoldTheHandoff, TestAWedgedClientIsClosedRatherThanHeld
2 SHOULD-FIXannounceViewer/announceStale assert a mode without reading it Fixed, as a class. One function, announceAs, holds the announce lock, reads (mode, gen) under it and reports what it read; no caller supplies a mode. The four per-path variants are deleted. A stale that would reach a live controller is sent as attached control. attachplane/ownership.go TestAControllerThatClaimsFromAStaleGenerationKeepsControl, TestAStaleAnswerNeverTellsALiveControllerItIsAViewer, TestADisplacementNoticeReportsTheModeItReads
3 SHOULD-FIXdemoteTo is the one transition that cannot refuse Fixed. demoteTo(from, gen) no-ops when the generation has moved past from; demote takes that generation from the renewal that was refused, and installs the viewer binding only when the demotion still stands. attachplane/ownership.go TestADemotionThatWasSupersededDoesNotDemote, TestDemoteToRefusesAGenerationThisAttachHasLeft
4 SHOULD-FIX — the authorization split is right at the service and unreachable from the CLI Fixed. A negotiated controller attach the policy refuses is admitted as a viewer when the policy grants viewing, with MayClaim false; internal/controld's pre-upgrade check mirrors it. An unnegotiated attach is still refused — it cannot be told it is a viewer. controlapp/attachments.go, internal/controld/attach.go TestAViewOnlyPrincipalWatchesAndMayNotClaim (rewritten: it used to pin the 403)
5 NITclaim is not idempotent and is client-triggered Fixed. A claim from the attach that holds control costs one lease read and never advances the generation. The read is not ceremony: it is how a controller displaced on another replica finds out, and answering from memory would close its only door out. attachplane/ownership.go TestAClaimFromTheCurrentControllerNeverAdvancesTheGeneration, TestAControllerDisplacedElsewhereRecoversOnOnePress
6 NIT — benign message reordering on the release path Fixed for free by #2: the notice reports the mode read at send time. The client prints [you have control] when it learns it that way. attachplane/ownership.go, internal/attachio/ownership.go TestControlWonInsideSomebodyElsesHandoffIsStillAnnounced
7 NIT — three test soft spots Fixed. The stale-acknowledgement drain has a test (deleting it costs the whole timeout); fakeSandbox.order is gone; both time.Sleep assertions are now a round trip through the same client pump. attachplane/ownership_test.go TestAStaleAcknowledgementNeverCostsTheNextHandoffItsWait

What the two reviews of this round then found

Two further Opus reviews (one independent, one adversarial with a 21-mutant
battery) were run on the diff above. They found that the bound from #1 had
been applied one level too wide, and that two of the fixes composed into a new
failure. Both are fixed, with tests:

Finding Disposition
A courtesy notice could close a healthy client: every ownership message carried the ack timeout, and the socket closed on any expired context, so a control_changed queued behind a snapshot killed the attach Fixed. The caller's context decides — a message this attach is owed carries the socket's write deadline, a message about somebody else's handoff carries one ack timeout — and ClientStream closes on its own budget only. The announce hold became a channel so acquiring it is bounded too. TestACourtesyNoticeNeverClosesAHealthyClient, TestAClientThatNeverDrainsIsClosedFromBehindTheWriteLock
A claim whose binding never landed took control anyway: unrecoverable, because the lease really was this attach's and the heartbeat renewed it Fixed. The generation is given back and the client is told what exists now. TestAClaimWhoseBindingNeverLandedGivesTheGenerationBack
demote re-read the generation it was demoting from, leaving the first part of its own window uncovered Fixed. The heartbeat passes the generation its renewal was refused for. A superseded demotion now also installs nothing at the sandbox.
An attach was registered in the owner table only after its opening resize was read — up to 15s in which a granted controller was invisible to every peer, so two clients could each be told they have control Fixed (predates this round). Registration happens first. TestAnAttachStillReadingItsFirstMessageIsDisplacedLikeAnyOther
The fan-out's parallelism and the runner-side deadline had no test; CloseNow on expiry survived the mutation battery Fixed. Three new tests; all three mutants now die.
One policy call too many at attach time Fixed: a principal the policy has just refused the controller is not asked again.

Mutation battery. The adversarial review ran 21 mutants against the first
version of this round: 14 caught, 7 survived. Those seven, and what happened
to each:

Mutant Now
serialise the fan-out caughtTestTheFanOutReachesEveryPeerAtOnce
drop install's deadline caughtTestABindingWriteThatNeverLandsDoesNotHoldTheHandoff
reorder demote (install before the plane-side write) caughtTestADemotionThatWasSupersededDoesNotDemote now reads the sandbox too
drop CloseNow on expiry caughtTestAClientThatNeverDrainsIsClosedFromBehindTheWriteLock
drop the per-peer deadline around the install pair removed, not tested — it capped at one ack timeout what install and installAndWait already cap at one between them. A bound that reads as load-bearing and is not is worse than no bound; the notice's deadline, which is the one that reaches a client, stays and four tests die without it
drop the mayAttach viewer mirror accepted — forward-compatibility with no seam in-tree: self-hosted's ownerOrAdmin answers both mode questions identically, so both branches behave the same. It is pinned at the service, which is where a narrower policy can be expressed
move displaceTo into the fan-out goroutine accepted — not a behaviour change worth a test: it is one locked step on the peer's own lock either way. On the caller it means every displaced peer stops being forwarded for before any I/O starts, which is a property worth keeping and not one worth a test that would pass either way

Every fix in both tables above was verified by reverting it and watching its
test fail; the failures are quoted in the commit messages.

One more thing the gates found on their own: sixteen tests started work at the
fake sandbox's dial-back rather than at the splice, which is a few calls later
— so a displacement landing in between installed nothing (install returns
errAttachNotSpliced at once and never retries) and the test waited out its
deadline. It bit under load before this round and bites harder now, because a
claim whose binding cannot be installed gives its generation back instead of
taking control. They all wait for a frame to cross the splice now.

Fifth review — dispositions

A fifth independent Opus review of c6d16cb ran a 22-mutant battery (20 died)
and twelve stress scenarios through the real plane under -race, and called
the module converged: the state machine and every fourth-round fix hold,
the one surviving semantic mutant is a coverage gap rather than a live bug,
and the single defect it found is one residual instance of the class the
fourth round named — reached through the decision whether to announce rather
than through an announcement.

Design note: docs/design/2026-09-11-controller-ownership-fifth-review.md.

# Finding Disposition Where Regression test (fails without the fix)
F1 MEDIUM-HIGH — a displaced controller is skipped and never told, while the taker is told it has control. displace read displaceTo returning moved=false as "already at this generation, therefore already told". Two paths move an attach's own state and then spend real time before announcing it (demote holds installAndWait for a whole ack timeout against a non-acking sandbox; sendStale can wait out a client's write budget), and a take-over landing in either gap skipped that peer and answered the taker. 1.7 s at production defaults with two screens both saying "you have control" Fixed. moved now decides only whether that peer's SANDBOX needs a new binding. The announcement is unconditional, and unconditionally safe because announceAs reads the mode and generation under the announce hold — the premise the fourth round's own fix created, and which the design note it invalidated predated attachplane/ownership.go TestAPeerAlreadyAtTheNewGenerationIsStillToldAboutIt (the reviewer's S10; 5/5 fail on c6d16cb)
F2 LOW — a claim that gives its generation back tells nobody. The store sits two generations on with a vacant holder while the previous controller is still control in the plane and still forwarded for, until its own heartbeat — and every viewer's next press is refused once too Fixed. The give-back fans the resulting generation out, with the number sendStale already reads, and waits: this is the only fan-out that can demote a peer which genuinely holds control attachplane/ownership.go TestAClaimThatGivesItsGenerationBackTellsThePeersToo
F3 LOW — the ownership vocabulary is filtered client→sandbox but not sandbox→client, so a sandbox's {"type":"attached","mode":"control","gen":"99"} reached the client. That client prints [you have control], stops claiming, and types into a plane that drops every frame Fixed. The runner pump drops attached/stale/control_changed the way the client pump drops control/control_ack. Dropped rather than fatal: ending the attach would hand a buggy sandbox a way to disconnect every client watching attachplane/splice.go TestASandboxsOwnershipMessagesNeverReachTheClient
F4 LOW--view is documented as never claiming, and Ctrl-\ still claimed Fixed, inert rather than re-worded — the flag's promise is the more useful of the two. Options.NeverClaim is set by the flag and nothing else; the key is swallowed, silently, as it already is on a device that has control internal/attachio/ownership.go, cmd/rainier/main.go TestViewNeverClaimsWhateverTheUserPresses
F5 INFORMATIONAL — the client write budget was a WHOLE-write deadline, so the largest frame the 16 MiB read limit allows demanded ≈273 KB/s of a client making steady progress Fixed by scaling, not by a comment: base + wire(payload)/rate, the rate measured against the base64 the socket actually carries. At 60 s + 64 KiB/s the largest frame gets 60 s + 256 s attachplane/stream.go TestTheWriteBudgetScalesWithTheFrame, TestALargeFrameIsGivenTimeInProportionToItself
F6 COVERAGE GAP — mutant M10′ (read (mode, gen) before acquiring the announce hold) survived the whole suite Fixed. A contention test holds one announcement open in Send, queues a second behind it, and wins control while it waits. M10′ dies 5/5 attachplane/ownership_test.go TestAnAnnouncementReportsTheStateItFoundWhenItGotTheHold
F7 NITclientWriteTimeout was a mutable package var three tests wrote Fixed. Base and rate are per-stream fields set by ClientStream; nothing shared is mutated attachplane/stream.go (the two F5 tests construct their own)

What the two reviews of this round then found

Two further Opus reviews of this diff — one independent, one adversarial,
both with mutation batteries — found four defects it had introduced, plus
documentation it had falsified. All fixed here, each with a test that fails
without its fix.

Finding Disposition
HIGH — F4 read askedView, which means "this attach is requesting view mode". reconnectOwnership sets exactly that on every reconnect whose previous attach ended a viewer, so a plain rainier attach that lost control and reconnected lost Ctrl-\ for the rest of the process — silently, with no way back but detaching Fixed: Options.NeverClaim is a separate fact from the mode being requested. TestAReconnectedViewerKeepsItsTakeControlKey
HIGH — F2's give-back fan-out did not wait, so a peer was flipped to view in the plane with its binding still in flight, and the next taker skipped its own installAndWait and was answered early Fixed: wait=true. The test asserts the binding has landed when the claim returns, rather than polling until it does
MEDIUM-HIGH — F1's unconditional announce reached attaches that are registered but not yet told what they are (registration precedes the first-message read, bounded only by attachFirstMsgTimeout), making a courtesy notice their opening answer: two [another device took control] lines for a plain viewer, one for a --view attach that should hear nothing Fixed: a peer whose state did not move AND which has been told nothing is left to its own opening answer, which reads the same state. A peer that moved is still told. TestAPeerThatHasNotBeenToldWhatItIsIsNotToldAboutSomebodyElseFirst
MEDIUM — the Send-level budget test passed with the scaling reverted: json.Marshal of a 2 MiB payload under -race costs most of a second, so the test was vacuous at exactly the -race -count=30 the gates run it under Fixed: 384 bytes at 1 KiB/s buys the same half second with negligible marshalling, and there is an upper bound as well as a lower one
MEDIUM-LOW — the budget scaled on the payload, not the base64 the socket carries (≈8% more throughput demanded than promised); one assertion beside it was a tautology for any base Fixed: wireSize, and the assertion checks the wire rate against the floor with the base subtracted
MEDIUM/LOW (docs)docs/cli-v0-contract.md said Ctrl-\ still works under --view; docs/terminal-controller-ownership.md said that flag's key is answered stale; the help text did not mention the key; two prior design-doc sentences this change falsifies were left standing — which is the thing F1's own finding was about Fixed: all four updated, the two prior sentences annotated in place with what superseded them
LOW — the S10 test could pass on the wrong producer if a run stalled past ControlAckTimeout; a comment attributed generation zero to finish, which never passes it Fixed: five seconds of margin plus a guard that fails a stalled run rather than concluding from it; attribution corrected
LOW (found by the gates) — both new --view tests pressed Ctrl-\ before the opening answer, when it is not yet this client's key: the reconnect test flaked ~1 run in 15, and the --view test would have passed on a press that asked nothing Fixed: both wait for the snapshot behind the answer. Separately, TestRunNoRaceOnFloodedOutputDuringDetach's 5 s wait for the flood to start failed ~1 run in 4 on an idle machine, at c6d16cb as well as here, and now waits generously

Deliberately not taken: a wedged client is now held ~5 minutes rather than
1 for the largest frame, and a new attach's first byte can wait one
Plane.step behind a wedged peer — both are the price of not disconnecting a
client that is making steady progress, and both cost resources rather than
correctness. A control_changed can still precede a peer's opening attached
when that peer's state did move, and the client prints its notice twice;
that is pre-existing, the notice is true there, and folding it is a change to
client notice policy with its own design note. The give-back's
control_changed view reads as "another device took control" when the lease
is vacant — closest word the vocabulary has, and a new type is a wire change.

Verification

All with RAINIER_TEST_DATABASE_URL / RAINIER_TEST_PG_DSN set to a real
PostgreSQL 17, so nothing skipped silently.

Gate Result
make verify pass
repotest on memstore (-v) pass — 42 tests, 0 skips
repotest on pgstore (-v) pass — 0 skips
go test ./internal/e2e/ -race pass, 0 skips
go test ./attachplane/ -race -count=20 pass (264 s)
go test ./internal/attachio/ -race -count=5, ./cmd/rainier/ -race pass
the fifth round's new tests, -race -count=30 pass

Every fix in the two tables above was verified by reverting it and watching
its test fail; TestAPeerAlreadyAtTheNewGenerationIsStillToldAboutIt fails
5/5 on c6d16cb itself, and mutant M10′ dies 5/5.

One thing the gates found that is not in this PR: controlapp's
TestPullWorkspaceOverflow takes 217 s under -race on its own and blows the
10-minute package timeout when the machine is busy. It is untouched by this
branch (make verify runs go test ./... without -race, and passes), and
it wants its own change.

Not in this PR

The state machine itself was not redesigned — three reviews now agree it holds
under every interleaving they could construct. Rate limiting is deliberately
absent (a limiter would also refuse the legitimate rapid hand-back after a
mis-press). A displaced peer that cannot be reached inside one acknowledgement
timeout loses its notice and nothing else: its pty fence is in force the
moment the store advances, the plane stops carrying what it types before the
fan-out starts, and its own heartbeat demotes it within one interval.

jiashuoz and others added 13 commits September 10, 2026 18:46
At most one controller per session, everyone else a viewer, handoff
explicit and atomic, and every input fenced by a generation at the PTY
rather than at the plane. Records the durable lease, the negotiated
handshake, the compatibility rules for the three separately deployed
parties, and what each of them is verified by.

Co-Authored-By: Claude <noreply@anthropic.com>
…port

The controller generation exists but nothing conditions on it: every
attach advances it and no caller reads it back, so control follows
whoever attached last. This is the durable half of fixing that, and it
changes nobody's behaviour yet.

control.SessionRepository gains two methods. CompareAndAdvance-
ControllerGeneration advances a session's generation from an expected
value in ONE predicated statement, which is the whole handoff guarantee:
two devices claiming from the same generation cannot both match, so one
gets the advance and one gets ErrStale. It vacates the lease in the same
step, which is why there is no separate release primitive — a controller
that leaves advances the generation, and that both frees the lease and
fences the bytes it had already sent. RenewControllerLease installs or
extends the lease, fenced by generation and holder, and is both a
claim's first act and its heartbeat afterwards.

The lease itself is two columns beside the generation (0013): an opaque
per-attach holder from crypto/rand — never a user, device or account
identifier — and an expiry. Expiry is passive and there is no sweeper: a
lease whose expiry has passed is simply not live, so a client that dies
without releasing cannot wedge control. Existing rows come out vacant
and claimable from the generation they already had.

controlapp/repotest gains the case that is worth having: eight
concurrent claims from generation 7, exactly one 8, seven stale answers,
and a row that is still at 8 afterwards. It is concurrent because the
sequential version passes for a read-then-write implementation every
time — checked by writing one, which fails this case on every adapter.

Also fixes freshDB in the pgstore suite, which rewrote the database in
RAINIER_TEST_PG_DSN by replacing the literal "/postgres?". Anyone
following the README and pointing it at a database of their own got the
admin DSN back for every "fresh" store, so unrelated cases shared one
database and failed on a perfectly good server.

Co-Authored-By: Claude <noreply@anthropic.com>
…e pty

The attach command can now ask for control conditionally, and the answer
travels all the way down to where input executes. Nothing negotiates yet
— the plane still grants no binding — so this changes nobody's
behaviour; it builds the thing the next commit switches on.

protocol/terminal gains the conditional-ownership vocabulary: the three
attach-URL parameters a client advertises with, the claim/release and
attached/stale/control_changed messages, and the control/control_ack
pair that installs a binding in a sandbox and confirms it. Generations
travel as decimal strings, because a uint64 past 2^53 is silently wrong
in the browser that renders the next consumer of this protocol. Every
field is omitempty and pinned by a test: a peer that negotiates nothing
writes the bytes it has always written.

control.AttachTerminal gains Negotiated and ExpectedGeneration, both
optional. An unnegotiated controller attach still advances the
generation unconditionally — a client that cannot be told it is a viewer
cannot be made one without breaking it — and that advance is exactly
what fences and notifies a negotiated client attached at the same time.
A negotiated attach claims only when control is free, or only from the
generation it presents, and lands in AttachmentViewer otherwise, because
"somebody else is typing" is an answer and not a failure.
AttachTarget carries the granted mode and a ControllerLeaseKeeper, so a
broker can run a handoff without ever being handed a repository.

The binding rides the frame that OPENS an attachment — dial_attach, then
the relay FrameOpen — so a sandbox installs it before it queues a byte
of screen, with no acknowledgement to order against. A mid-attach
handoff arrives as a control message and is answered with a
control_ack, which is what will let the plane refuse to tell a taker it
has control until the fence protecting it is actually in place.

The fence itself is in internal/session, at the process, because the
plane is not where input executes: a keystroke accepted from a
controller that was displaced a moment later can already be past it. A
bound attachment writes only while it is the controller at the session's
current generation and its frame was stamped with that same generation;
an unbound one writes unconditionally, which is the compatibility rule
for an older plane and is safe because under the old message set only
one client could have been sending. The pty follows the controller, so a
phone watching a laptop's session no longer squeezes it to phone width.

Co-Authored-By: Claude <noreply@anthropic.com>
…the rest

The plane now carries the decision the application makes. A client that
advertises the capability on its attach URL is told its mode and
generation before the first snapshot or output byte, and can claim and
release control on the stream it already has. A client that advertises
nothing receives exactly today's message set.

A claim is not answered until the sandbox has confirmed the new
generation. Two attachments reach one sandbox over one conn but on
independent paths, so the plane cannot order a displacement against a
keystroke the previous controller has already sent; waiting for the
control_ack means every frame from the old controller that arrives after
the answer is fenced, and every frame that arrives before it arrived
while the handoff had not happened for anybody. A sandbox that predates
this protocol never acks, so the wait is bounded and the handoff
proceeds without it — a new plane must require nothing an old one cannot
supply.

The displaced controller is told twice over. On this replica the plane
pushes control_changed at once; anywhere else — and as the backstop
everywhere — its own heartbeat renewal stops being accepted within one
interval and it demotes itself. Its fencing is immediate either way,
because the generation moved before either notice was sent. A clean
detach releases, which advances the generation, so the next attach
claims with no click and the leaver's already-sent bytes stop being
executable.

The plane also stamps every client frame it forwards with the generation
that attach holds — a legacy client's included, since the client cannot
stamp its own. That is what makes an unstamped frame reaching a sandbox
mean an older PLANE and nothing else, which is the compatibility rule
the sandbox's fence relies on.

The read model gains one additive object, `controller {generation,
held}`. It names nobody: a client learns that somebody has control,
never who or on what device. The generation is a decimal string there
for the same reason it is one on the wire.

Co-Authored-By: Claude <noreply@anthropic.com>
`rainier attach` keeps its default and its zero clicks: on one laptop it
claims control and behaves byte for byte as it always has. On a second
device it attaches as a viewer and prints one line naming that another
device has control and the key that takes it. --view never claims;
--take claims once on attach, and once is the whole of it.

Ctrl-\ takes control, beside the existing Ctrl-] detach, and is
intercepted ONLY inside an attach a server actually answered. Against a
plane that does not speak conditional ownership it is forwarded as an
ordinary byte, so a user attached to an older deployment keeps every key
they had. Ctrl-] releases as it detaches, so the next attach needs no
key at all.

Reconnect presents what this device actually held. A controller presents
its generation and resumes only while nobody took it; a device that was
superseded while it was away comes back a viewer and says so. A viewer
stays a viewer rather than quietly acquiring control because a network
blip happened to free it. Nothing claims on its own anywhere: --take is
spent by the attempt that used it, and a refusal is answered with one
line rather than another claim.

A client that asks for conditional ownership and is never answered
settles as an ordinary attach the moment terminal traffic arrives — it
types, stamps no generation, and stops eating Ctrl-\. That is the new
client + old plane pairing, and it is a test.

`rainier info` gains a Controller row: this device, another device, or
none. It needs no new wire field and discloses nobody: the API says
whether somebody holds control, never who, and "this device" is worked
out from the generation this CLI was last granted — nobody else can hold
a generation without advancing past it.

Co-Authored-By: Claude <noreply@anthropic.com>
docs/terminal-controller-ownership.md is the operator- and integrator-
facing note: the rule, the parameters and why each is the number it is,
the handshake and the message set, where the fence lives and why it is
at the pty rather than at the plane, and the four compatibility pairings
in a table — including the rule that input with no generation is treated
as the current controller's, which is safe only because under the old
message set no second client could have been sending.

The CLI contract gains the same facts additively, in the sections that
already own them: attach's ownership behaviour and its two new flags,
info's Controller row, and the session document's new `controller`
object. Nothing already documented changed.

Co-Authored-By: Claude <noreply@anthropic.com>
Review found that the plane's client pump forwarded any message type it did
not recognise verbatim onto the sandbox socket — and `control`, the verb that
installs a mode and a generation at the pty, is one of them. A viewer could
therefore promote itself, or name a generation past every generation the
session will ever reach and leave nobody able to type for the life of the
process, since the pty's fence only ever goes up.

The plane now drops a client's `control` and `control_ack` rather than
carrying them; its own handoff writes them straight onto the runner socket and
never goes through that pump, so this costs the handoff nothing.

Behind it, at the pty: an attachment that was OPENED unbound is never bound
later. A binding travels on the frame that opens an attachment — that is what
leaves no window between the size and the binding — so a handoff arriving on
an attachment that never had one did not come from a plane at all. It came
from whatever is on the other end of an unmanaged attach socket, such as a
runner's local debugging endpoint, which has no control plane above it.

Both fences have a regression test that fails without them.

Co-Authored-By: Claude <noreply@anthropic.com>
Four things review found in what one attach is granted.

A dropped connection cost the controller its own session. The plane releases
whenever an attach ends — however it ended — and a release advances the
generation, so a device dialling back presented a generation the row had
already moved past and came back a VIEWER of a session nobody was controlling,
told that another device had control. It also latched: the client then asked
for `mode=view` on every later retry. Presenting a generation now decides only
whether to claim under a live lease, not what to claim from; a lease that is
vacant or expired is claimed by whoever asks, which is the same rule a first
attach gets.

A claim whose first lease renew came back stale was answered with success. A
stale renew means the generation the claim just won has already been advanced
past, so two devices were told at once that they had control. It is now a lost
claim. A renew that fails for any OTHER reason still stands: it failed at a
generation that is still this attach's, and the generation is the authority
while the lease is only the hint.

A negotiated attach is authorized for the mode it can REACH, not the one it
opens in. It can claim control at any moment on its own stream — that is the
take-control key — so a host whose policy separates viewing from controlling
would otherwise find `mode=view` a way around it. An unnegotiated viewer is
still authorized as a viewer: it has no way to claim.

And a broken entropy source fails the attach before anything is claimed,
rather than advancing the generation and displacing whoever had control on its
way to reporting the failure.

Each has a regression test that fails without it.

Co-Authored-By: Claude <noreply@anthropic.com>
A viewer was never told the generation had moved. It kept the number it saw
when it attached, so its first press of the take-control key claimed from a
generation that no longer existed and was answered "somebody else got there
first" — about a session nobody was using, after the controller had simply
walked away. Only the second press worked. `displace` now announces the
generation to every attach behind it: a controller is demoted, and a viewer
is told the number, which its client reads silently. A release and a departing
attach announce it too.

A take-over that IS an attach answered the taker before it had displaced
anybody, where a mid-attach claim deliberately waits for the sandbox to
confirm the new binding first. The narrow window between them is exactly the
one the whole acknowledgement exists to close, so an attach now keeps the same
order: displace, wait, then answer.

Two handoffs in flight on one attach — a claim from the client pump and a
demotion from the heartbeat — shared one acknowledgement channel and could
consume each other's answer, leaving one of them to wait out the whole
timeout. They are serialised now, and a leftover acknowledgement is dropped
rather than mistaken for an answer.

A demotion whose generation read fails falls back to the generation the attach
already held rather than to zero, so the common case, a read that merely timed
out, still leaves the client able to take control in one press.

Also adds the plane-side test for a pairing the docs claim and nothing
exercised: a legacy attach displacing a negotiated controller, which is what
makes admitting a legacy client as a take-over safe for the device it took
control from.

Co-Authored-By: Claude <noreply@anthropic.com>
…ts size

Review found `--view` unenforced on the client: the flag reached the attach
URL but not the client's own idea of its mode, which stayed empty until a
server answered. Against a new plane that was a window of wasted bytes.
Against a plane that predates conditional ownership — the pairing the
compatibility matrix exists for — it was the opposite of what the flag says:
that plane admits every attach as an unconditional controller, so `--view`
took control from whoever had it and its keystrokes executed. The mode is now
held locally from the first byte, and an attach that asked to watch is not
told "another device has control", which on that plane might not even be true.

`--take` spent its one claim only if the attach opened as a viewer. An attach
that opened holding control left it unspent, so it fired minutes later, when
somebody else took control — a snatch-back nobody pressed a key for. The first
answer spends it, whatever the answer said.

And gaining control now says how big this terminal is. A viewer's resizes are
suppressed on the way out, so the size the session holds for a watching
attachment is the one it had when it attached; without this, taking control
snaps the pty to a size several window changes old.

Each has a regression test that fails without it; the size one drives a real
pty, because a pipe has no size to report.

Co-Authored-By: Claude <noreply@anthropic.com>
`controller.held` on the session view asked the wall clock while the
attachment service measured the same lease against the composed one, so
nothing could drive an expiry through the JSON in a test — and a replica whose
clock the services trust would have had a read model that did not. The clock
is now the Server's, shared by both.

Adds the missing test for the one hop between "what the client asked for" and
"what the application granted": the attach handler reading the three
negotiation parameters off the URL and putting them on the command. Both ends
were well covered and the wire between them was not, so a regression that
dropped a field would have returned every attach to unconditional semantics
silently. It walks a real websocket through a real runner to a real relay,
and pins the mode and generation on both the client's answer and the frame
that opens the sandbox's attachment.

Co-Authored-By: Claude <noreply@anthropic.com>
Review's mutation probes found three places where the code was right and
nothing would have noticed if it stopped being.

The generation on an incoming FRAME, as distinct from the generation on the
attachment's binding, was load-bearing in exactly one scenario and had no test
for it: a controller that was displaced and then took control back holds a
binding at the current generation, so the rule about its binding says yes, and
the only thing between the shell and a keystroke it typed two generations ago
is that the frame carries the generation it was sent under.

The additive wire promise was pinned for `protocol/terminal` and for nothing
else, though the binding also crosses two hops whose ends ship in different
artifacts: the `dial_attach` a control plane sends a runner, and the relay
frame that opens an attachment inside the sandbox. Both now have a golden for
the unbound shape and a round trip for the bound one.

Which of the two generations on a forwarded frame the sandbox reads — the
client's or the plane's — was neither documented nor tested. The plane's wins,
and a fence a client could write its own value into would be no fence at all.

Also: a create no longer keeps a caller-supplied lease in the in-memory store,
where the Postgres one always dropped it, and the conformance suite now says
so on both; and the plane's test logger stops writing to a finished test's
`t`, so a leaked pairing's fifteen-second TTL cannot panic the binary out from
under every other test in a long run.

Co-Authored-By: Claude <noreply@anthropic.com>
…something

Review found the design doc describing an earlier shape of the wire — the
capability as a field on the first `resize` rather than three query parameters
— and stating the pty's compatibility rule as a conjunction that, implemented,
would have broken the pairing it exists for. Both now match the code, along
with the fourth method on the lease keeper and the package that actually owns
the reconnect tests.

"Its fencing is immediate either way" was stronger than the truth. The
generation moves the instant a take-over commits, and on this replica the plane
waits for the sandbox to confirm the new binding before telling anybody they
have control; across replicas the sandbox learns it from the taker's own
opening frame or the displaced controller's next heartbeat. All three are now
written down separately, because they are different guarantees.

Three things a reader would otherwise have had to discover: a legacy client
displaced by a negotiated one is fenced with no notice it can render and no key
that takes control back, which is the same rule that protects the negotiated
client running in the other direction and is what the CLI being tagged last is
for; presenting a generation is a take-over request rather than only a resume;
and a negotiated attach is authorized for the controller it can become, so
`mode=view` is not a way around a policy.

Co-Authored-By: Claude <noreply@anthropic.com>
@jiashuoz
jiashuoz marked this pull request as ready for review September 10, 2026 21:37
@jiashuoz

Copy link
Copy Markdown
Member Author

A third, independent review of this head (Opus, read-only, fresh clone, mutation probes on a throwaway copy). The fence at execution, adapter atomicity and monotonicity, the four compatibility pairings, and wire additivity all verified clean; details in the session that will apply the fixes. The findings, verbatim:

1. should-fix — NextControllerGeneration vacates the lease in memstore but not in pgstore (nor in the controlapp fake), and the conformance suite does not pin it.
internal/controld/memstore.go:407-412 clears ControllerHolder/ControllerLeaseExpiresAt; internal/controld/pgstore/sessions.go:378-394 only does controller_generation = controller_generation + 1. controlapp/attachments_test.go:625 also leaves the lease. control/ports.go:104-110 says nothing either way, and controlapp/repotest exercises NextControllerGeneration (S11) and the lease (S13) but never together — so S12/S13 pass on both adapters with opposite behaviour.

Consequence on the adapter that actually ships to Cloud: a legacy (unnegotiated) attach — the one path that still uses this primitive, and the whole point of the "old client + new plane" row — advances the generation and leaves the displaced controller's holder and future expiry on the row. For up to ControllerLeaseTTL (30s) the session view reports controller.held: true for a holder with no authority, and AttachmentService.grant (controlapp/attachments.go:305) sees Live()==true && expected != gen and grants the next negotiated controller attach viewer instead of controller — on memstore the same attach gets control. Two adapters, opposite answers, no test.

Fix: add controller_holder = '', controller_lease_expires_at = NULL to the pgstore UPDATE, say so in the NextControllerGeneration doc comment on control/ports.go, and extend controlapp/repotest S13 to call NextControllerGeneration over a live lease and assert the row comes back vacant (this is the same divergence class as the PR's own I15 fix, caught for CreateSession and missed here).

2. should-fix — a claim that is still waiting for its sandbox ack is not protected against a second claim, so two attaches are both told attached: control.
attachplane/ownership.go:75-79 (set is an unconditional write) and attachplane/ownership.go:203-217 (claim does CAS → installAndWaito.set(ModeControl, gen)), combined with attachplane/ownership.go:401-408 where displace reads other.get() and later writes other.set(...) outside one critical section.

Interleaving: A claims (CAS 1→2) and blocks in installAndWait for up to ackTimeout. B claims from 2 (CAS →3). B's displace reads A as still (view, 1), takes the viewer branch, and sets A to (view, 3). A's installAndWait then returns and A unconditionally overwrites itself to (control, 2) and sends itself attached mode=control gen=2. A is never corrected until its next heartbeat (≤5s), during which its client prints [you have control], the plane forwards and stamps its keystrokes as gen 2, and the pty discards every one of them. Reproduced against the real plane:

A was told: mode=control gen=2
B was told: mode=control gen=3

(probe: two viewer attaches with a non-acking sandbox and ControlAckTimeout: 2s, A claims, 300ms later B claims from 2). No double-execution — the pty fence holds, which is the point of having it — but the design's "at most one controller at any moment" is violated at the plane and at the user's screen.

Fix: replace set with a monotonic advance(mode, gen) bool that refuses under o.mu when gen < o.gen; in claim, if advance refuses, send stale/control_changed with the current generation instead of attached: control; and move displace's otherGen >= gen check and its write into one method on ownership so the read and the set are under the peer's own lock.

3. should-fix — authorizedAs escalates a negotiated --view attach to AttachmentController, which locks a view-only principal out entirely, and the edge pre-check now asks a different question than the service.
controlapp/attachments.go:356-361 returns AttachmentController for any negotiated attach, including mode=view, and AttachTerminal (line 382) passes that to AuthorizeAttachment. A host policy that grants view but not control — exactly the Cloud collaboration policy this seam exists for, and the policy I2's fix was written to protect — therefore refuses rainier attach --view outright. Separately, internal/controld/attach.go:61,146 now pre-checks with asked.Mode (viewer for --view) while the service checks controller, so the stated invariant in that function's comment ("the same ownerOrAdmin policy adapter, asked the same question") no longer holds; under such a policy the client gets a 101 upgrade followed by a policy-violation close instead of a clean 403. No in-tree failure, because ownerOrAdmin.AuthorizeAttachment ignores the mode (internal/controld/adapt_policy.go:74).

Fix: authorize the attach at the mode it opens in, and move the controller check to the claim path — i.e. a second AuthorizeAttachment(..., AttachmentController) before controllerKeeper.Claim on a mid-attach claim. That needs negotiated to stop being inferred from AttachTarget.Controller != nil (attachplane/ownership.go:61); carry "this client negotiated" and "this client may claim" as two facts so a view-only viewer can still be told things without being granted the claim.

4. nit — a negotiated controller attach that dies at the pairing stage has already displaced the incumbent.
controlapp/attachments.go:392 calls grant (which claims) before s.broker.Attach, and attachplane/plane.go:174 displaces before the dial_attach is even sent. If the runner never dials back (TTL, attachplane/plane.go:216), finish releases and the previous controller is left a viewer of a session nobody controls for an attach that never produced a terminal. Inherent to the binding riding dial_attach, and recoverable in one Ctrl-\ because displace announced the new generation, so I would only document it in docs/terminal-controller-ownership.md rather than restructure.

5. nit — two dead fields.
internal/attachio/ownership.go:28 legacy is written by settleLegacy and never read. internal/attachio/ownership_test.go:28,39 fakePlane.speaks is never consulted in serve, so newFakePlane(false, …) and newFakePlane(true, …) are identical — the "old plane" in TestNewClientOldPlane is expressed solely by the script containing no ownership messages (which is faithful to a real old plane, so this is cosmetic, but the flag cannot fail).

6. nit — no bound on claim frequency. A client with control can loop claim/release on its own stream; each claim advances the generation and runs displace(..., wait: true), serially waiting up to ackTimeout per peer. Authorized-user nuisance only, no escalation, but worth a line in the doc.

Merge recommendation: merge after fixes 1, 2 and 3.** The fence at execution is correct, genuinely independent of the plane, and proven so by mutation; the adapters are atomic and monotonic; the compatibility matrix is honestly tested. But #1 makes the two shipped adapters disagree about the primitive the legacy-client pairing runs on, with the conformance suite — the thing the Cloud port will be graded against — silent on it; #2 lets two devices be told they hold control at once, which is the invariant the branch exists to establish, and is reproducible in a few hundred milliseconds; #3 denies --view to exactly the view-only principal the policy seam was added for. All three are small, local changes.

A fix session has been dispatched with these findings and will post its dispositions here. The Cloud draft (rainier-cloud#90) will be re-pinned to the fixed head.

jiashuoz and others added 13 commits September 10, 2026 22:02
A third independent review of the pushed head found three should-fix
defects and three nits. All six were verified against the branch before
anything was written; none is refuted.

The design records what each one costs — a legacy attach that leaves the
displaced holder's lease on the row in Postgres and nowhere else, a claim
that can announce control it has already lost, a view-only principal
locked out of a session it may watch — and what closes it, including the
two behaviours that are documented rather than changed and why.

Co-Authored-By: Claude <noreply@anthropic.com>
NextControllerGeneration is the unconditional take-over — the primitive a
legacy attach uses, the "old client + new plane" row of the compatibility
matrix — and it displaces whoever held control. memstore cleared the
holder and the expiry; pgstore advanced the counter alone. The port said
nothing either way, and the conformance suite exercised the primitive and
the lease but never together, so both adapters passed it with opposite
behaviour.

On the adapter that ships, a legacy attach therefore left the DISPLACED
holder's identity and future expiry on the row. For the rest of the 30s
TTL the session view reported a live lease held by somebody with no
authority, and the next negotiated controller attach read that lease and
was admitted a viewer — where memstore gave it control.

The port now says vacating is part of the contract, pgstore's UPDATE
clears both columns, and controlapp's test fake does the same.

repotest S13 gains the case that would have caught it: install a live
lease, advance unconditionally over it, and require the row back vacant
and the displaced holder's heartbeat refused. It FAILS on pgstore without
this fix ("an unconditional advance left the displaced holder's lease
behind") and passes on memstore, which is the divergence itself.

Co-Authored-By: Claude <noreply@anthropic.com>
…control

A claim advances the generation in the store and then waits, for as long
as the acknowledgement timeout allows, for the sandbox to confirm the new
binding. A second claim can win inside that wait. The first one then woke
up, wrote (control, its own generation) over its own displacement, and
told its client `attached mode=control` — so two devices printed
[you have control] and one of them had none.

The pty fence held, so nothing double-executed; what broke is the rule the
whole design has, that at most one client is the controller at any moment.
Until the next heartbeat (<=5s) the displaced client's keystrokes were
forwarded, stamped with a superseded generation, and discarded by the pty
while its screen said it was in charge.

`set` was an unconditional write and `displace` read a peer with `get` and
wrote it with `set` outside one critical section. Three named transitions
replace it, each doing its read and its write under one hold of the lock
it belongs to:

  advance   monotonic; refuses a generation older than the one held. A
            refused claim answers `stale` with the generation that exists,
            and re-points the binding it installed at what this attach
            actually is, so the sandbox agrees with the plane.
  demoteTo  always a viewer, at the newer of the two generations. It
            cannot refuse: being wrong about the number is survivable,
            believing you still have control is not.
  displaceTo  the "already at or past this generation" check and the write,
            together, under the peer's own lock.

TestAClaimSupersededWhileItWaitedIsNeverToldItHasControl makes the
interleaving deterministic rather than raced — the first attach's sandbox
predates the protocol and never acknowledges, the second's answers at once
— and FAILS without this fix with "a claim superseded while it waited was
told it has control at generation 2".

Co-Authored-By: Claude <noreply@anthropic.com>
authorizedAs returned AttachmentController for ANY negotiated attach,
including mode=view. A host policy that grants viewing without granting
driving — the Cloud collaboration policy this seam exists for — therefore
refused `rainier attach --view` outright, locking a view-only principal
out of a session it may perfectly well watch.

It also left the edge asking a different question from the service: the
pre-upgrade check in handleClientAttach uses the mode the client asked
for, so under such a policy the caller would get a 101 upgrade followed by
a policy-violation close instead of the clean 403 that function exists to
give. Nothing failed in tree only because ownerOrAdmin ignores the mode.

An attach is now authorized for the mode it opens in, and the privilege it
might REACH is asked for where it is exercised: controllerKeeper.Claim
asks AuthorizeAttachment(..., AttachmentController) before it touches the
store, live rather than cached, so a grant revoked mid-attach is honoured
at the next press of the take-control key. The escalation that check
replaced stays closed — a mid-attach claim still cannot reach control the
caller was never authorized for.

That makes `Controller != nil` the wrong carrier for "this client
negotiated", because a view-only client must still be TOLD its mode and
generation while never being admitted a controller. AttachTarget carries
the two facts separately, Negotiated and MayClaim, and the plane refuses
an unauthorized claim without leaving the replica.

Two regression tests, both FAILING without this fix:
  TestAViewOnlyPrincipalWatchesAndMayNotClaim ("a negotiated view attach
  under a controller-denying policy: control: denied")
  TestAViewOnlyAttachIsToldEverythingAndClaimsNothing ("no stale message
  reached the client; saw [attached]")

Co-Authored-By: Claude <noreply@anthropic.com>
`ownership.legacy` was written by settleLegacy and never read anywhere.
Removing it leaves settleLegacy with nothing to do, which is correct
rather than surprising: never having settled IS the legacy state, and it
is what every behaviour that state implies already reads — stamp returns
zero, keyActive is false, mayType is true. The function and its two call
sites go with the field, and the comment that explained the state stays
where the traffic arrives.

`fakePlane.speaks` was never consulted in serve, so newFakePlane(false, …)
and newFakePlane(true, …) built identical planes and the flag could not
fail a test. The "old plane" in TestNewClientOldPlane is expressed by its
script containing no ownership message, which is faithful — that is
exactly and only what an old plane is on the wire.

No regression test is constructible for removing code nothing reads. The
behaviour the field pretended to record is already pinned end to end by
TestNewClientOldPlane, which drives a real socket and asserts that an
unanswered client types, stamps no generation, sends no claim, prints no
ownership notice, and forwards Ctrl-\ to the remote application.

Co-Authored-By: Claude <noreply@anthropic.com>
Three behaviours the code has and the operator doc did not say.

A take-over commits before the terminal exists: the binding rides the
dial_attach, so a negotiated controller attach displaces the incumbent
before the runner has been asked to dial back, and an attach that dies at
its pairing TTL leaves the previous controller a viewer of a session
nobody controls. One press of the take-control key takes it back, because
the displacement announced the generation. Reversing the order is not
available — until the runner dials back there is no socket to install a
binding on — so it is written down rather than restructured.

Nothing bounds claim frequency. A client with control can loop
claim/release on its own stream, and each claim waits serially on every
peer's sandbox. It is an authorized user's nuisance against their own
session, and it is deliberately not rate limited: a limiter would also
refuse the legitimate rapid hand-back after a mis-press.

And what a host policy that separates viewing from driving now gets: the
attach authorized at the mode it opens in, the claim authorized on its
own and live.

Co-Authored-By: Claude <noreply@anthropic.com>
Review found the same lie one step further along than the last fix
reached. A claim answers itself only after it has displaced every peer on
this replica, and that loop waits on each displaced peer's SANDBOX, once
per peer and up to the acknowledgement timeout each. A second claim can
win inside it, and the mode read before the loop is not the mode this
attach holds after it. The attach path does the same thing: the broker
reads the granted mode, displaces, and then sends the value it read.

The result is worse than the window closed last commit, because nothing
corrects it. A client that believes it is the controller sends no claim of
its own, so Ctrl-\ does nothing, and this attach's heartbeat renews
nothing because the plane knows it is a viewer — so no stale renewal ever
demotes it. Its screen says [you have control] for the life of the attach
while the plane drops every keystroke.

Every message that tells a client what it is now goes out under one
`announce` hold that also does the state read: announceClaim,
announceOpening, announceViewer, announceStale. A claim asks one question
once, at the end — does this attach still hold the generation it won? —
which answers both waits, its own and the displace loop's.

announce is only ever taken alone: a claim finishes displacing its peers
before it announces anything about itself, and a displacement takes the
peer's announce and no other, so two attaches cannot each hold one and
wait for the other's.

Two regression tests, each FAILING without its half:
  TestAClaimSupersededWhileItDisplacedIsNeverToldItHasControl ("a claim
  superseded while it displaced its peers was told it has control at 2")
  TestATakeOverAtAttachTimeIsToldWhatItIsAfterItDisplaced ("an attach
  displaced before it was ever told anything was told it has control")

Co-Authored-By: Claude <noreply@anthropic.com>
finish read the mode and the generation in two separate critical sections,
with a context being built between them. release, ten lines above, already
reads both in one step; that inconsistency was the tell.

This attach's own heartbeat demotion can complete between the two reads.
A demotion holds `control` for the whole of its bounded wait on the
sandbox and only then writes the CURRENT generation — the one belonging
to whoever took over, possibly on another replica. So the first read says
"still the controller" and the second returns the winner's generation, and
the release advances past a device that legitimately has control, on its
own generation, from an attach that is walking out of the door. That
device's screen says [you have control] while every keystroke is fenced at
the pty, until its next heartbeat notices.

One read, as release does.

TestFinishReleasesOnlyTheGenerationItActuallyHeld races the demotion
against the disconnect 500 times and asserts both halves: no release at a
generation this attach never held, and the live controller still holding
its lease. It FAILS without the fix ("a departing attach released
generation 2, which it never held").

Co-Authored-By: Claude <noreply@anthropic.com>
sendStale started at zero and only moved off it if the store read
succeeded. demote does the opposite on purpose, and the design doc spends
a paragraph on why: it falls back to the generation this attach already
holds, because being told zero is worse than being told a stale number.

Zero is a generation no row is ever at. A client told it presents zero on
every later claim, and a predicated advance from zero can never match a
non-zero row — so every subsequent press of the take-control key is
refused, for the life of the attach, because one store read timed out.
That is the store being briefly unusable, which the heartbeat's own
comment calls expected and survivable.

sendStale now seeds from this attach's own state, like demote and like the
policy refusal a few lines above it.

TestARefusedClaimIsNeverToldGenerationZero drives a claim whose generation
read fails and FAILS without the fix ("a refused claim whose generation
read failed was told 0, want the 7 this attach holds").

Co-Authored-By: Claude <noreply@anthropic.com>
Review raised three consequences of the new AttachTarget fields.

A composer that predates Negotiated sets the keeper and nothing else, and
still compiles. Reading Negotiated literally would silently stop telling
that client its mode, its generation and every handoff — indistinguishable
from an older plane, from the client's end. A keeper has always meant
exactly "this attach negotiated", so the broker still reads it that way.
MayClaim is read the other way, because it is a privilege that needs a
keeper to exercise: never inferred, never wider than the target carries.
The contract now states both invariants and how a target that breaks one
is resolved.

A negotiated claim is also always ANSWERED. Returning in silence, which is
what a negotiated target with no keeper used to get, leaves the
take-control key doing nothing at all — the one outcome a client cannot
tell from a broken connection.

And the attach-time grant no longer re-asks the policy. It is the one
claim whose question was answered microseconds earlier on the very same
mode, so asking again doubles a Cloud collaboration-grant lookup on the
attach path, and a backend that blinks between two identical calls would
fail the attach ErrDenied — reporting a dependency outage as "not
authorized to attach to this session" after the door said yes. The claims
a client makes on its own stream are still asked every time, which is what
honours a grant revoked mid-attach.

Three regression tests, each FAILING without its fix:
  TestATargetThatPredatesTheNegotiatedFlagIsStillToldEverything
  TestANegotiatedClaimIsAlwaysAnswered
  TestAnAttachAsksTheHostsPolicyOnceAtTheDoor ("the policy was asked
  [controller controller]; want exactly one controller question")

Co-Authored-By: Claude <noreply@anthropic.com>
…annot fail

Adversarial review ran mutation probes over the new code and found five
that survived the whole tree. Each one is a property this change was
written to establish, and each is now pinned:

  advance accepts the generation this attach ALREADY holds. A departing
  peer's release announces the current generation to everybody behind it,
  which can set a still-waiting claimant to view at exactly the generation
  it just won; refusing that would tell the winner "somebody else got
  there first" about a generation it owns and holds the lease on.

  demoteTo takes the max. A demotion reads the current generation and can
  be overtaken before it writes; walking the number down would leave the
  client claiming from a superseded generation, refused every time.

  displaceTo refuses a generation this attach has passed, and does its
  check and its write as ONE step. The second is the half of the original
  defect that had no test at all: split apart, a claim landing between the
  read and the write is clobbered back to a viewer at an OLDER generation
  while its client has already been told it has control. It is asserted as
  an invariant over 2000 rounds rather than as one reproduced
  interleaving, and it fails immediately against the split version.

  A refused claim re-points the binding it installed. The existing test
  read the client stream only and never looked at the loser's sandbox.

The fifth mutation — displace's `gen == 0` early return — could not be
killed, because it is unreachable: displaceTo now refuses zero for every
attach, since every attach is at or past it. The guard is removed and the
reason written down; the behaviour it guarded is pinned by
TestDisplaceAtGenerationZeroTouchesNobody, which passes either way and
says so.

Test fixtures renamed to the repository's four-letter form (att_aaaa).

Co-Authored-By: Claude <noreply@anthropic.com>
…nting one bit twice

Four things review found written down wrongly or not at all.

ControllerLeaseKeeper.Claim enumerates its errors and did not mention the
ErrDenied it now returns when the host refuses this attach the controller
privilege. That is the same divergence class the pgstore lease fix was
about — an adapter behaviour the port does not state — so the port states
it, including that a broker treats it as "still a viewer" and not as a
transport failure. The neighbouring sentence about a nil keeper meaning
"the host does not negotiate" now points at the field that carries it.

Two more SessionRepository fakes still advanced the counter and left the
lease behind: controlapp's session stub, which models the lease correctly
in its other two methods and was internally inconsistent in exactly the
way pgstore was, and the fleet fake. No test observes the difference
today; the next one to try would.

The operator doc claimed a view-only client's take-control key is
"answered 'you are still a viewer'". It is answered `stale`, which the CLI
renders "[somebody else got there first; press Ctrl-\ to try again]" —
wrong about the cause and suggesting a key that cannot succeed for that
principal. The doc now says what actually happens, and why the two
refusals share one message: telling them apart needs a message the wire
does not have, and the compatibility matrix is the reason not to add one
in a fix. Operators are told to say "read-only" themselves.

And attachio's `settled` and `answered` were provably the same bit — one
assignment sets both, nothing else writes either — with two comments
asserting they mean different things. One bit, both meanings.

The extended conformance case also drops a follow-up renew that read like
a second vacancy check and was not one (it failed on the generation
mismatch alone). What replaces it is the operational consequence of
vacancy: a different holder can install a lease at the new generation at
once, which the holder predicate only permits over an empty holder.

Co-Authored-By: Claude <noreply@anthropic.com>
The design said `advance` closed the window a claim opens on itself. It
closed the first of two: the displace loop is the longer one, and the
attach path has it as well. It also did not know about `finish`'s split
read or `sendStale`'s fallback to a generation no row is ever at.

The approach section now describes the one place that says what an attach
is, and the verification list names the cases that pin it — including the
five mutations that survived the whole tree before this round.

Co-Authored-By: Claude <noreply@anthropic.com>
@jiashuoz

Copy link
Copy Markdown
Member Author

A fourth independent review (Opus, read-only, fresh clone, 14-mutant battery: 12 caught) of head 42475de. The state machine itself is verified correct under every interleaving it could construct; what remains is the layer around it. Findings, verbatim, and the verification table:

Findings

1. BLOCKER — one peer that stops reading freezes every handoff on the session; no write is bounded anywhere in displace

attachplane/ownership.go:563-587 (displace) walks peers serially and, for each, does two unbounded I/O steps: install/installAndWaitrunner.Write(ctx, …) (ownership.go:223) and other.announceViewer(ctx)o.stream.Send(ctx, …) (ownership.go:182). Only the ack wait is bounded (ownership.go:252-265); the writes are not. The ctx in both callers is unbounded: the splice's r.Context() (splice.go:60) for a claim, and the attach request's ctx for an attach (plane.go:174). There is no websocket write deadline and no ping anywhere in attachplane/internal/controld/internal/relay (grepped).

Interleaving — no concurrency needed, just one wedged viewer:

  1. Viewer V is attached and stops reading its socket (lid closed, TCP zero-window, paused JS tab). No RST, so nothing closes it.
  2. Controller B presses take-control. claimkeeper.Claim succeeds (ownership.go:303): the store generation is advanced, so the previous controller is already fenced at the pty.
  3. advance + displace (ownership.go:309-311) reach V: displaceTo moves it, then announceViewer blocks in Send forever.
  4. B is never told (announceClaim at :318 is never reached), so B's client keeps mode=view and sends nothing. The session now has zero working controllers, and B's heartbeat keeps renewing the lease it does not know it holds, so every new negotiated attach is admitted a viewer.

Worse on the attach path: plane.go:174 runs displace(..., true) before p.attaches.park (:186) and p.host.Send (:204), so the same wedged viewer stops a brand-new controller attach from ever reaching its runner — the new client sits on an upgraded socket with no output and no close.

Proved through the real plane with the real dial-back websocket (a probe test under -race):

--- FAIL: TestProbeAStuckPeerStallsAClaimForever (3.31s)
    the winning claim was never answered: one peer that stopped reading stalled it
    (lease is already advanced, so the taker IS the controller and does not know it)
--- FAIL: TestProbeAStuckPeerStallsANewControllerAttach (3.31s)
    the new controller attach never even reached its runner: a peer that stopped
    reading blocked broker.Attach before it parked the socket
--- PASS: TestProbeTheStallEndsWhenTheStuckPeerDrains (0.31s)   // same setup, peer drains

Fix: give every peer-facing step its own deadline and stop serialising them —

for _, other := range p.owners.peers(winner) {
    go func(other *ownership) {           // bounded fan-out; one deadline for the whole loop
        pctx, cancel := context.WithTimeout(ctx, p.ackTimeout)
        defer cancel()
        ...
    }(other)
}

A displaced peer's notice is a courtesy (its heartbeat is the mechanism, its pty fence is the safety), so a peer that cannot be reached inside one ack timeout must not hold the taker. Also add a write deadline (or ping/pong) in ClientStream (stream.go:69) so a wedged client is eventually closed rather than held. Related, same class: announceClaim holds o.announce across o.install's socket write (ownership.go:337 inside the hold taken at :329), so a slow sandbox on attach A blocks peers' displacement of A — move that corrective install outside the announce hold.

2. SHOULD-FIX — announceViewer and announceStale assert a mode without reading it: the mirror of the 418a8aa fix, never applied

announceClaim (ownership.go:328-339) and announceOpening (:346-353) read (mode, gen) inside the announce hold and say what the attach is. announceViewer (:358-365) and announceStale (:369-373) read only the generation and hard-code mode: view / stale. Both can therefore tell a live controller it is a viewer.

Reachable end to end today, with no race at all: a controller whose client sends claim with an expected it no longer holds (any client that reads controller.generation — newly exposed in v0wire/sessions.go and cmd/rainier/main.go:session.Controller — and claims from it). claim has no "am I already the controller" guard (ownership.go:283), so keeper.Claim fails ErrStalesendStale (:306) → announceStale:

--- FAIL: TestProbeEndToEndAControllerIsToldStaleAndStopsTyping
    client told stale@"4"; plane says control@4; store says 4 held by "att_aaaa"

Consequence: the client switches to viewer and stops typing (attachio/ownership.go:61), while the plane still forwards for it and its heartbeat keeps renewing gen 4 — so for the whole 30s ControllerLeaseTTL nobody can type and every new negotiated controller attach is admitted a viewer (controlapp/attachments.go:332). One more take-control press recovers it.

The announceViewer half is the same hole on the displacement path (unit-level probe):

plane state = control@3; the client was told control_changed mode=view gen="3"

Fix: one function, used by all four paths —

func (o *ownership) announceAs(ctx context.Context, typ string) (string, uint64) {
    o.announce.Lock(); defer o.announce.Unlock()
    mode, gen := o.get()
    o.send(ctx, terminal.ServerMessage{Type: typ, Mode: mode, Generation: terminal.GenOf(gen)})
    return mode, gen
}

with announceStale suppressed (or sent as attached/control) when mode == ModeControl, plus an early return in claim when o.controlling() — re-claiming from yourself only advances the generation and fences your own in-flight keystrokes, which is exactly what attachio/ownership.go:156 already refuses to do client-side.

3. SHOULD-FIX — demoteTo is the one transition that cannot refuse, and that is the wrong asymmetry

advance (:119-121) and displaceTo (:154-155) both refuse a backwards move; demoteTo (:131-139) raises the generation to the max but sets mode = ModeView unconditionally. A heartbeat demotion computed at generation N (demote, :426-436: StateinstallAndWaitdemoteTo) can spend the full ackTimeout in installAndWait and then demote an attach that has since won N+1 through its own claim. The comment's justification ("being wrong about the number is survivable, believing you still have control is not") is about the number; the bug is the mode. End state: the plane says viewer, the store says this attach holds the lease at N+1 — the same 30s dead window as #2.

Narrow with the first-party CLI (its claim() refuses to fire while it believes it controls, and the only thing that tells it otherwise is the announcement at the end of demote), wide for a client that claims from controller.generation.

Fix: demote captures the generation it is demoting from and passes it down; demoteTo(from, to) no-ops when o.gen > from. The demotion was a decision about a generation this attach has already left.

4. SHOULD-FIX — the authorization split is right at the service and unreachable from the CLI

controlapp/attachments.go:423 authorizes cmd.Mode, and mayClaim (:392-402) asks the controller question separately — correct, and pinned by TestAViewOnlyPrincipalWatchesAndMayNotClaim, which also pins that a negotiated controller attach under a controller-denying policy is ErrDenied at the door. But cmd/rainier/main.go:defaultOwnership() makes plain rainier attach a mode=control request, and internal/controld/attach.go:64,146 refuses it pre-upgrade with a 403. So a view-only principal — the exact case mayClaim's docstring says the old code "locked out of a session it may perfectly well watch" — gets not authorized to attach to this session from a plain rainier attach and must know to type --view. grant's own rule ("every refusal lands the attach in AttachmentViewer … never an error") is not applied to the policy refusal.

Fix: in AttachTerminal, when cmd.Negotiated && cmd.Mode == Controller and the policy refuses controller, ask it for viewer and admit at viewer (mayClaim is already false on that path); mirror it in mayAttach. Or, smaller: have the CLI retry once with Mode: ModeView on a 403 and print the viewing notice.

5. NIT — claim is not idempotent and is client-triggered

ownership.go:283: a claim from the current controller advances the generation, re-installs, re-displaces every peer (one ack timeout each for the ex-controller) and fences its own in-flight keystrokes. A client can repeat it at will. Guard with o.controlling() (see #2).

6. NIT — benign message reordering on the release path

releasedemotedisplace(…, false) (:412) can send control_changed view@G to a peer that is mid-claim and about to be told attached control@G (its advance at :309 accepts its own generation, which TestAdvanceAcceptsTheGenerationThisAttachAlreadyHolds pins). The client prints "another device took control" then "you have control". Final state is correct; the notice is wrong. Fixed for free by #2's announceAs if the peer's mode is re-read at send time.

7. NIT — three test-suite soft spots

  • installAndWait's stale-ack drain (:245-248) survives deletion: no test covers it. It exists so a handoff does not burn the whole timeout on a previous handoff's ack; add a test with a sandbox that acks the first binding late.
  • fakeSandbox.order (ownership_test.go:88, written at :122) is never read — a field nothing reads, exactly the kind b8916e2 removed.
  • ownership_test.go:441 and :492 assert "no stdin arrived" after time.Sleep(100ms). Both follow a real synchronisation point, so it is the acceptable use of a sleep, but a forwarding regression would only fail probabilistically; a control round trip after the stdin would make it deterministic.

Verified safe, and by what

Mutation battery (14 mutants, go test -count=2 ./attachplane ./internal/relay): 12 caught, including faithful reproductions of all four last-round regressions. Survivors were the stale-ack drain (finding 7) and an unrealistic adjacent-get() split in finish — the real original bug (a keeper.State round trip between the two reads) is caught by TestFinishReleasesOnlyTheGenerationItActuallyHeld.

Transition Where What makes it safe
own claim accepted ownership.go:303-318 store CAS CompareAndAdvanceControllerGeneration (controlapp:212) — exactly one winner per expected; advance under o.mu (:116-124); answer read inside o.announce (:328-339)
own claim stale :306, :385-393 sendStale falls back to o.gen, never 0 (pinned; d273a9f mutant caught). Hole: no mode check → finding 2
displaced by peer claim :563-587, :151-159 displaceTo does check+write under one hold of the peer's o.mu; refuses a generation the peer has passed (TestDisplaceToAndAdvanceAreEachOneStep, 2000 iterations)
displaced by legacy attach plane.go:174, controlapp:305 NextControllerGeneration advances and vacates the lease (61624cb); the legacy client gets no notice — documented tradeoff, docs/terminal-controller-ownership.md:122; probed and confirmed
own release :403-413 Release is a CAS (controlapp:252), so a release that lost changes nothing; generation advance is what makes the fence real
own finish (socket close) :477-498 one o.get() at :485 + CAS release; TestFinishReleasesOnlyTheGenerationItActuallyHeld catches the real regression
heartbeat renew / expiry :442-468, controlapp:229-242 RenewControllerLease fenced on generation and holder; only ErrStale demotes, transient errors retry inside the 6-renews-per-lease budget
reconnect with expected generation controlapp:332-343 one read, then CAS from that read; racing attaches produce one winner (TestTwoDevicesRacingFromOneGenerationHaveOneWinner)
sandbox ack timeout :239-266 handoff serialises the pairs; bounded wait; proceeds fenced at the plane alone. Dropping the wait fails 5 tests (mutated and confirmed)
two claims, either order probe 4 attaches racing one generation, -race -count=4: always exactly one controller, at the store's generation, no attach above it
close racing a peer claim probe 30 rounds -race: the taker is always told something true, never a generation above the store's
pty fence (sandbox side) internal/session/session.go:mayWriteLocked, observeLocked bound attachment writes only when bind.Mode==control && bind.Generation==controllerGen && frameGen==controllerGen; controllerGen only rises; Bind refuses an attachment opened unbound
lock discipline owners.mu is released before the displace loop; every critical section holds one attach's lock at a time; order is always announce→mu or handoff→mu, never inverted — no cross-attachment cycle. Exception: announce across a socket write (finding 1)
lease keeper lifetime splice.go:62-64 one heartbeat goroutine per splice, one context.CancelFunc, no Stop API to double-call; no goroutine leak (-race clean)

Answer to the question asked: the state machine itself is correct — the store CAS, the single-step advance/displaceTo, the handoff serialisation and the pty fence hold under every interleaving I could construct or stress. The residue is not the state machine; it is the layer around it. Two of the four announcement paths still assert a mode instead of reading it (the exact defect class 418a8aa fixed on the other two), demoteTo is the one transition with no backwards guard, and the handoff does unbounded serialised I/O while the taker waits. That last one is not fragility, it is a liveness bug that any single paused client triggers.

Merge recommendation: do not merge until 1 and 2 are fixed; 3 and 4 should land with them. Not a redesign — the state machine does not need one — but finding 1 means one paused browser tab freezes control handoff for an entire session, which is precisely the multi-device scenario this PR ships, and finding 2 is the same review defect recurring on the paths the last round did not touch. Folding the four announcement paths into one state-reading announceAs and bounding the displace loop removes the class rather than the instance, which is what the repeat findings in this module are telling you to do.

A fix session has been dispatched with the instruction to remove the defect class (one state-reading announce function, a bounded fan-out displace, a backwards guard on demote, viewer fallback for view-only principals) rather than patch instances. It will post dispositions here and re-pin rainier-cloud#90.

A fourth independent review found one liveness bug (a single peer that stops
reading freezes every handoff on the session), the third round's announcement
defect recurring on the two paths it did not touch, the one transition with no
backwards guard, and an authorization split the first-party CLI cannot reach.

This records what each one is, what it costs, what closes it, and the one
behaviour that changes on purpose: a view-only principal's plain `rainier
attach` becomes a viewer instead of a 403.

Co-Authored-By: Claude <noreply@anthropic.com>
jiashuoz and others added 5 commits September 11, 2026 00:11
…t first

Four paths announced a client's ownership and two of them asserted a mode
instead of reading it: announceViewer hard-coded `view`, announceStale
hard-coded `stale`. Both could therefore tell a live controller it is a
viewer. That is the defect 418a8aa fixed on the other two paths — the third
time this shape has been found in this module — so this removes the class
rather than the instance: there is now ONE announcement function, announceAs,
it takes the announce hold, reads (mode, gen) under it, and reports what it
read. No caller supplies a mode. announceClaim, announceOpening,
announceViewer and announceStale are gone.

Two decisions move inside that hold with it. A claim that no longer holds what
it won is answered the way a lost race is, and a `stale` that would reach a
LIVE controller is sent as `attached control` instead — the case that was
reachable with no race at all: a client reading controller.generation and
claiming from a value it no longer held was refused by the store, told stale,
and stopped typing, while the plane kept forwarding for it and its heartbeat
kept renewing its lease. Nobody could type for the rest of the 30s lease.

`claim` now returns early for the current controller, which is the other half
of that: re-claiming from yourself only advances the generation, fences your
own in-flight keystrokes and costs every peer an acknowledgement timeout. The
first-party CLI already refuses to send one; nothing at the plane did.

Reading the mode at send time also fixes the release path's reordering, where
a peer mid-claim was told "another device took control" a moment before
"you have control".

Tests, each failing without the change (mutations run and confirmed):
TestAControllerThatClaimsFromAStaleGenerationKeepsControl and
TestAClaimFromTheCurrentControllerNeverAdvancesTheGeneration drive it through
the real plane; TestAStaleAnswerNeverTellsALiveControllerItIsAViewer and
TestADisplacementNoticeReportsTheModeItReads pin the two announcement
decisions directly. TestThePlaneStampsItsOwnViewOverTheClients now claims from
a viewer, because a controller's claim no longer advances anything.

Co-Authored-By: Claude <noreply@anthropic.com>
displace walked the session's peers serially and did two unbounded writes to
each — the binding to that peer's sandbox and the notice to that peer's client
— on an unbounded context, with no write deadline anywhere in the plane. One
viewer that stops READING its socket (a closed lid, a TCP zero window, a
paused browser tab; no RST, so nothing closes it) was therefore enough to
freeze every handoff on the session: the taker's claim had already advanced
the store, so nobody could type, and the taker was never told it had won, so
its client stayed a viewer while its heartbeat renewed a lease it did not know
it held. On the attach path it was worse — the displacement runs before the
client socket is parked and before the dial_attach is sent, so one stuck
viewer stopped a new controller attach from ever reaching its runner.

The fix is the class, not the case. displace is now a bounded fan-out: each
peer on its own goroutine, so a peer that cannot be reached is never in front
of one that can, and each peer-facing step under Plane.step — one
acknowledgement timeout, the same budget for a binding, its acknowledgement
and a notice. install bounds its runner write with it, and o.send bounds its
client write with it, so the announce mutex can no longer be held across a
socket that is not draining. Nothing in the package writes to a peer without a
deadline now; that is the property, not the individual call sites.

The ordering the contract promises is unchanged: displace still returns only
when the fan-out is done, so the taker is not told it has control until the
displaced controllers' sandboxes have the new binding. What changed is that
the bound is two acknowledgement timeouts in total rather than none at all,
once per peer, in a queue. A displaced peer that cannot be reached inside it
loses only the notice: its pty fence was in force the moment the store
advanced, the plane stops forwarding for it before the fan-out starts, and its
own heartbeat demotes it within one interval.

ClientStream gets a write deadline of its own, so a socket that has taken
nothing at all is eventually closed rather than held with a writer parked on
it. It is deliberately far above any frame a live client on a slow link could
be slow with; what it catches is a peer that is not draining.

Tests, verified against the pre-fix shape: TestAStuckPeerDoesNotStallAClaim,
TestAStuckPeerDoesNotStallANewControllerAttach and
TestAStalledExControllerIsFencedEvenThoughItsNoticeCouldNotBeDelivered all
fail (the claim is never answered, the dial_attach never leaves) and
TestOneStalledPeerDoesNotHideAnothersDisplacement fails four runs in six,
which is the map order deciding whether a stalled peer is walked first.
TestAWedgedClientIsClosedRatherThanHeld does not fail without the deadline, it
hangs — which is the production symptom.

Co-Authored-By: Claude <noreply@anthropic.com>
… others

advance and displaceTo both refuse a backwards move; demoteTo was the
exception, and the justification written above it ("being wrong about the
number is survivable, believing you still have control is not") is true of the
NUMBER and was applied to the MODE.

A heartbeat demotion is decided at the generation this attach held when its
renewal was refused, and it then spends a store read and a bounded sandbox
wait in flight. An attach that wins a newer generation inside that window — a
peer takes control, this device takes it straight back — was demoted anyway:
the plane then says viewer while the store says this attach holds the lease,
so nobody can type, this attach's heartbeat keeps renewing a lease nobody can
use, and every negotiated attach behind it is admitted a viewer, for the rest
of the 30s lease. Which is the outcome the unconditional write was there to
prevent, arrived at from the other side.

demoteTo takes the generation the demotion was decided at and no-ops when this
attach has moved past it. demote reads that generation before the store call,
and now writes the plane's half of the fence FIRST (one locked write that
cannot fail, and it stops this attach being forwarded for at once), telling
the sandbox only when the demotion still stands — installing a viewer binding
for an attach that has since won a newer generation would fence the controller
it just became.

TestADemotionThatWasSupersededDoesNotDemote drives the five-step interleaving
through the real plane, holding the demotion still in the store read with a
gated keeper; TestDemoteToRefusesAGenerationThisAttachHasLeft pins the
transition. Both fail without the guard (mutation run: the controller is told
it is a viewer at the generation it holds the lease on).

Co-Authored-By: Claude <noreply@anthropic.com>
The mode-aware attachment policy was right at the service — an attach is
authorized for the mode it opens in, and taking control later is a separate
question — and unreachable from the first-party CLI. A plain `rainier attach`
asks for `mode=control`, because zero-click on one laptop is what it has
always been, so a principal a host grants viewing and not driving got
"not authorized to attach to this session" for a session it may perfectly well
watch, and had to know to type `--view`. That is the exact principal mayClaim
exists for, and `grant`'s own rule — every refusal lands the attach in
AttachmentViewer, never in an error — was not applied to the policy refusal.

AttachTerminal now asks the policy for the mode the client asked for and, when
a NEGOTIATED controller is refused, asks whether it may watch instead; if it
may, the attach is admitted as a viewer and everything below asks about the
mode it was admitted in. mayClaim then asks the controller question separately
and gets the same no, so the store is never touched by its take-control key.
The client is told `attached, view` before it paints a screen and prints the
viewing notice, which is the thing the operator documentation could previously
only ask hosts to tell such users out of band.

An unnegotiated controller attach is still ErrDenied: it cannot be told it is
a viewer, so admitting it as one would leave a terminal that silently does not
type.

internal/controld's pre-upgrade check mirrors it, so the two halves still ask
the same question and a caller the service would admit is not answered 403
before the upgrade. It is not observable in self-hosted — ownerOrAdmin answers
both questions the same way — which is why it is pinned at the service, where
a narrower policy can be expressed.

TestAViewOnlyPrincipalWatchesAndMayNotClaim previously pinned the refusal;
it now pins the admission, the viewer grant, MayClaim staying false, the
keeper still being handed over, the order the policy is asked in, and the
unnegotiated attach still being refused. docs/terminal-controller-ownership.md
says all of it, plus what a bounded parallel handoff means for an operator.

Co-Authored-By: Claude <noreply@anthropic.com>
…sleeping

Three soft spots the fourth review found in the suite rather than in the code.

installAndWait's stale-acknowledgement drain survived deletion: no test
covered it. It exists because the acknowledgement channel holds exactly one,
so a previous handoff's late answer displaces the next handoff's — which then
waits out the whole timeout for an answer it has already been given, while the
person who pressed the take-control key looks at a terminal that will not
type. TestAStaleAcknowledgementNeverCostsTheNextHandoffItsWait drives it with
a sandbox that answers the instant the binding is written, which is what a
fast socket does; deleting the drain makes it take the full two seconds.

fakeSandbox.order was written and never read — the same kind of field b8916e2
removed from attachio.

The two "no stdin arrived" assertions slept 100ms after a real
synchronisation point, so a forwarding regression would only have failed
probabilistically. awaitPumpCaughtUp replaces the sleep with a round trip
through the same client pump: a claim from generation zero, which no row is
ever at, so it is refused, nothing moves, and its answer proves everything
queued before it has been processed.

Co-Authored-By: Claude <noreply@anthropic.com>
@jiashuoz
jiashuoz marked this pull request as draft September 11, 2026 00:28
jiashuoz and others added 6 commits September 11, 2026 01:16
Two regressions this round introduced, both found by review, both the same
mistake in opposite directions: a bound that belonged to one caller was
applied to everybody.

o.send wrapped EVERY ownership message in one acknowledgement timeout, and
ClientStream closed the socket whenever any context expired. A websocket
serialises its writes, so a handoff's courtesy notice queued behind a snapshot
the plane was replaying to that same client expired waiting for the write lock
— and killed the attach. One take-over anywhere on the session disconnected a
client that was merely reading a scrollback over a slow link. Now the caller's
context decides: a message this attach is OWED (its opening `attached`, the
answer to its own claim) is bounded only by the socket's write deadline,
because dropping it leaves a client believing something untrue, while a
message about somebody else's handoff carries one acknowledgement timeout,
because it is a courtesy. ClientStream closes on its own budget and on nobody
else's. The announce hold became a channel for the same reason: acquiring it
has to be bounded too, or a peer's displacement queues behind a message this
attach is writing to a client that stopped reading.

And a claim whose binding never reached its sandbox took control anyway. The
store CAS had succeeded, so the generation and the lease were this attach's;
the pty discarded every keystroke, because that attachment's binding still
said what it said before; and nothing could repair it — the heartbeat renewed
happily, and this round's own idempotency guard then answered the client's
next press out of the same wrong state. A claim that cannot be fenced at the
sandbox is not a claim: the generation goes back, and the client is told what
exists now. (A sandbox that merely never acknowledges is not this case — that
is an older sessiond, and the handoff proceeds fenced at the plane alone.)

The idempotency guard also checks its belief before answering from it. A
controller displaced by an attach on ANOTHER replica reads `control` here
until its own heartbeat is refused, up to one interval later, and the
take-control press is its user's only way out of that; answering it from
memory closed that door. One store READ, which advances nothing, reopens it.

Tests: TestACourtesyNoticeNeverClosesAHealthyClient and
TestAClientThatNeverDrainsIsClosedFromBehindTheWriteLock pin the two halves of
the close rule; TestAClaimWhoseBindingNeverLandedGivesTheGenerationBack and
TestAControllerDisplacedElsewhereRecoversOnOnePress pin the two claim rules.
All four verified against the mutant they exist for.

Co-Authored-By: Claude <noreply@anthropic.com>
…e at once

Two more from review, both about a window rather than a value.

demote re-read the generation it was demoting FROM, at the top of itself. The
window the backwards guard exists for opens earlier than that — the moment
keeper.Renew is refused — so a claim landing between the refusal and that read
produced from == o.gen, the guard did not fire, and the attach that had just
won a newer generation was demoted anyway. The heartbeat now passes the
generation its renewal was refused for, and release passes the one it gave up.
The test holds the refusal itself in flight rather than the store read inside
demote, which covers the whole window, and it now also pins that a superseded
demotion installs NOTHING in the sandbox: a viewer binding written for an
attach that has since won fences the controller it just became, and the
heartbeat renews happily because the lease really is its own.

And the broker registered an attach in the owner table only after reading the
client's opening resize. For a controller attach the application has already
advanced the generation, so for the whole of that read — up to
attachFirstMsgTimeout, fifteen seconds, on a client that need only be slow —
this replica was serving a controller no peer could see. A claim on another
attach took its peer list without it, nothing displaced it, and its own
opening announcement then told it it had control at a generation somebody else
had passed. Two devices, both printing [you have control], until the loser's
next heartbeat. Registration now happens before the read, and what the opening
announcement says is read after it.

Both mutants confirmed caught (the demotion tells the controller it is a
viewer; the attach is invisible to its peers).

Co-Authored-By: Claude <noreply@anthropic.com>
…notice

The fan-out's own parallelism was not covered: with the per-step deadlines in
place a serial walk still finishes, so nothing would have noticed the
goroutines going away — and a serial walk is what makes a session with several
paused devices pay one deadline per device before the taker hears anything,
which is the session this feature ships for.
TestTheFanOutReachesEveryPeerAtOnce costs six deadlines when serialised and
one when not. The deadline on the sandbox-facing half had no test either;
TestABindingWriteThatNeverLandsDoesNotHoldTheHandoff waits ten seconds without
it.

The CLI prints [you have control] when it learns it from a `control_changed`.
A claim this client won can be answered by a peer's displacement first — the
plane reads what the attach IS at send time, so that notice says `control` —
and the `attached` behind it then finds a client that already holds control
and says nothing. The person took control and was told nothing at all.

Also: one fewer policy call at attach time (a principal the policy has just
refused the controller is not asked again), two comments that had drifted from
what the code does, and helper deadlines that no longer report a busy machine
as a bug — 15s for "the plane should have done this by now", and the two
handoff timings these tests measure raised to 1.5s, which is where a loaded
machine was dropping a binding write and failing an unrelated assertion.

Both documents say what changed: the design doc records what the two reviews
of this round found and why a displaced peer's binding is best-effort (the
taker's own binding is what raises the sandbox's fence), and the
operator-facing page says what a self-claim costs, what a bounded parallel
handoff means, and that a take-over that cannot be installed is given back.

Co-Authored-By: Claude <noreply@anthropic.com>
…ffer

Both socket tests arranged "another writer owns this socket" by writing eight
megabytes at a peer that was not reading, which is true only while the
kernel's buffers stay smaller than the payload. Under -count=10 they did not:
the write landed, the test that depended on a queue behind it asserted against
nothing, and its still-running goroutine then raced its own cleanup restoring
clientWriteTimeout.

coder/websocket's Writer takes the conn's write lock and holds it until it is
closed, which is exactly the state being described — so the tests take it. Both
are now deterministic, and one of them kills a mutant that survived the
adversarial battery: dropping the explicit CloseNow leaves the socket open
behind a client that has taken nothing for its whole budget, because the
library's own teardown only fires for a write that is in flight.

Co-Authored-By: Claude <noreply@anthropic.com>
The fan-out wrapped its own acknowledgement timeout around the install pair,
which caps at one timeout what install and installAndWait already cap at one
between them: install bounds its write, and a write that fails takes the
acknowledgement wait with it. Nothing could tell the wrapper's presence from
its absence — a mutation confirmed the whole suite passes either way — so it
is a bound that reads as load-bearing and is not.

The notice's deadline stays at the loop, because the write it bounds is the
one that reaches a client, and four tests fail within seconds without it.

Co-Authored-By: Claude <noreply@anthropic.com>
Sixteen tests started work the moment the fake sandbox's dial-back returned,
which says the socket was accepted and not that the plane has bound it: the
splice does that a few calls later. A displacement landing in between installs
nothing — install returns errAttachNotSpliced at once and never retries — so
the binding a test was waiting for never arrived, and it waited out its whole
deadline. The suite's own awaitSpliced comment said this; the tests predate it.

It bit under load and it now bites harder: a claim whose binding cannot be
installed gives its generation back rather than taking control the sandbox
cannot honour, so a test whose attach was not spliced yet gets a `stale` where
it expected `attached`. That is the production behaviour working; the tests
have to say what they mean.

Every one of them now waits for a frame to cross the splice (awaitSpliced for
an attach that may type, awaitViewerSpliced for one that may not), which is
only true once the plane holds the socket. Three full `go test ./...` runs and
`./attachplane -race -count=10` clean.

Co-Authored-By: Claude <noreply@anthropic.com>
@jiashuoz
jiashuoz marked this pull request as ready for review September 11, 2026 02:25
@jiashuoz

Copy link
Copy Markdown
Member Author

A fifth independent review (Opus, read-only; 22-mutant battery, 20 caught; twelve stress scenarios through the real plane under -race). Verdict: converged — merge after F1, a three-line fix with a verified reproduction. Verbatim:

Findings — most severe first

F1 — Medium‑High. A displaced controller can be skipped and never told, while the taker is told it has control

attachplane/ownership.go:721-724

was, moved := other.displaceTo(gen)
if !moved {
    continue          // <- asserts "already at gen" == "already told". It is not.
}

displaceTo returning moved=false means the peer's state is already at or past gen. Three paths move an attach's own state to the new generation and then spend real time before announcing it — demote (ownership.go:553, demoteTo then installAndWait, up to one ControlAckTimeout) and sendStale (ownership.go:519-522, displaceTo then announceAs, which can wait the full clientWriteTimeout on a client that has stopped reading). A take‑over landing in that gap skips the peer with continue, wg.Wait() returns at once, and the taker is answered.

Interleaving (deterministic, 6/6 runs, probe S10):

  1. B claims — CompareAndAdvance 1→2, B holds the lease.
  2. B waits on its own sandbox's control_ack (here 300 ms; a real sandbox can be slower).
  3. A's heartbeat renews generation 1, is refused ErrStale, calls demote(ctx, 1): reads 2 from the store, demoteTo(1, 2) moves A's own state to view@2, then A blocks inside installAndWait(view, 2) for the whole acknowledgement timeout because A's sandbox does not acknowledge (an older sessiond — the pairing this PR deliberately supports — or merely a slow one).
  4. B's ack lands, B advances, p.displace(ctx, B, 2, true)A.displaceTo(2)o.gen >= gencontinue. A is skipped entirely.
  5. B is told attached control 2. A has been told nothing.
  6. A's installAndWait times out and A is finally told control_changed view 2 — measured 1.70 s later at the production default ControlAckTimeout=2s.

The pty fence holds throughout (A's binding is control@1, the session's controllerGen is 2), so nothing double‑executes. What breaks is the invariant the feature exists for, and the module's own comment at ownership.go:43-52 says why that is not cosmetic: "a client that believes it is the controller sends no claim, and this attach's heartbeat renews nothing." A does self‑heal here, so the damage is bounded rather than permanent.

This is the round‑four defect class arriving through the one door the round‑four fix did not close: announceAs was made state‑reading so no announcement can assert a stale mode; the decision whether to announce at all was left asserting one. The premise is recorded in docs/design/2026-09-10-controller-ownership-review-fixes.md:229-231"displaceTo refusing must not send anything… a control_changed naming an older number would walk its client backwards." True when the notice carried a caller‑asserted number; false once announceAs began reading the number under the hold.

Fix (verified):

was, moved := other.displaceTo(gen)
wg.Add(1)
go func() {
    defer wg.Done()
    if moved && was == terminal.ModeControl {
        if wait { _ = other.installAndWait(ctx, terminal.ModeView, gen) } else { _ = other.install(ctx, terminal.ModeView, gen) }
    }
    nctx, cancel := p.step(ctx)
    defer cancel()
    other.announceAs(nctx, terminal.TypeControlChanged, 0)
}()

announceAs reads state under the hold, so the extra notice to an already‑current peer names that peer's real mode and number and cannot walk anybody backwards; attachio.observe folds it to no notice when nothing changed. Verified: S10 0/5 failures (was 6/6), S1 0/45 runs with an overlap (was 5/70), full attachplane suite green at -count=20 (283 s) and -count=2. Take S10's shape as the regression test — no current test pins the behaviour either way.

F2 — Low. A claim that gives its generation back tells nobody

attachplane/ownership.go:397

Probe S12: after the give‑back the store is at generation 3 with a vacant holder, while the attach that actually held control is still control@1 in the plane and is still forwarded for, until its next heartbeat (≤5 s). Every viewer's next press is also refused once, because nobody was told the new number — the exact case displace's own doc says the viewer notice exists for. Fix: after Release, fan the resulting generation out (p.displace(ctx, o, current, false) using the number sendStale already reads).

F3 — Low. The ownership vocabulary is filtered client→sandbox but not sandbox→client

attachplane/splice.go:85-95 vs :129-137

The client pump explicitly drops TypeControl/TypeControlAck. The runner pump consumes control_ack and forwards everything else verbatim, so a sandbox's {"type":"attached","mode":"control","gen":"99"} reaches the client outside announceAs (reproduced, S12b). A client told that prints [you have control], then stops sending claims (internal/attachio/ownership.go:164) and types into a plane that drops every frame — a wedged viewer whose only exit is detaching. Only a buggy or compromised sessiond produces it, and that sandbox could execute the keystrokes itself, so this is hardening: drop attached/stale/control_changed on the runner pump the way control is dropped on the client pump.

F4 — Low. --view is documented as never claiming, and Ctrl‑\ still claims

cmd/rainier/help.go:216, internal/attachio/ownership.go:161-168, controlapp/attachments.go:442

The attach is genuinely admitted a viewer, but ownership.claim() never consults askedView, and the service sets MayClaim true for a view‑mode attach whose principal may drive. Either make the key inert under --view or soften the help to "attaches as a viewer".

F5 — Informational; the round‑four risk is handled, one residual

attachplane/stream.go:126

The failure mode I was asked to look for is correctly avoided. context.WithTimeout(ctx, 60s) under a 2 s caller deadline returns a child of the parent with no timer of its own (verified empirically: both Err()s become non‑nil together), so wctx.Err() != nil && ctx.Err() == nil can never be true on the caller‑deadline path and a courtesy notice can never close a client. Pinned by TestACourtesyNoticeNeverClosesAHealthyClient, and independently by probe S8 (a snapshot held by a slow client for 8× the acknowledgement timeout still arrives and the splice survives). Residual: the 60 s budget is a whole‑write deadline and attachReadLimit is 16 MB, so the largest frame this stream can carry needs ≈273 KB/s sustained or a progressing client is CloseNown. Either scale the budget by message size or say in the comment that 60 s assumes frames well under the read limit.

F6 — Coverage gap, code correct

attachplane/ownership.go:463. Mutant M10′ — read (mode, gen) before acquiring the announce hold and use that value — survives the entire PR suite. That is the literal shape of the defect the announce mutex exists to prevent; it only shows under contention on one attach's announce. My S1 kills it. A test that holds one attach's announce hold while a peer wins control would pin it.

F7 — Nit

attachplane/stream.go:67: clientWriteTimeout is a mutable package var three tests write. Safe today (no t.Parallel() in the package); a future parallel test races it.

Invariants, and what makes each hold at this head

Invariant Status What makes it hold
One controller once handoffs settle Holds CompareAndAdvanceControllerGeneration is one predicated statement (memstore under the store mutex; pgstore one UPDATE), so two claims from one expected produce one advance. advance/displaceTo each do check‑and‑write under one hold of o.mu and refuse backwards moves. installAndWait keeps the taker unanswered until the displaced controller's sandbox has the new binding. Stress: 70 runs of 10 attaches × 60 claims — at most one attached control per generation, 0 exceptions; the plane settled with exactly one controller at the store's generation every run.
A client is only ever told its true mode and generation Holds per announcement; fails at the decision to announce One announceAs is the only sender; it reads state under the hold and makes both downgrades there (M8, M9 killed). But F1 skips the announcement entirely.
A generation announced never exceeds the store's Holds Every number in a message comes from o.get() inside the hold; every write to o.gen comes from a store read (grant, Claim, State) or from a peer's store‑derived number. Pinned by S1 (no announced generation exceeded the lease maximum, 70 runs) and TestARefusedClaimIsNeverToldGenerationZero.
No unbounded I/O under a lock or while a taker waits Holds o.mu is released before every write (install copies runner first; announceAs uses get()); announce is a channel so acquiring it is bounded too; every peer‑facing step carries p.step (M2, M5 killed); displace is a parallel fan‑out that wg.Wait()s (M1, M3 killed). Stress: a claim and a new controller attach behind a peer that had stopped reading were answered in 206 ms and 403 ms at a 200 ms acknowledgement timeout.
Every displaced controller is told, or its stream ends Does not hold as stated F1. The true weaker statement: every displaced controller is fenced at its sandbox and at the plane before the taker is told, and is told within one heartbeat interval.
A departing attach releases only what it held Holds finish takes mode and generation in one get() (M21 killed); Release(0) is a no‑op; the repository Release is predicated on the generation.
No goroutine or socket leak Holds S11: 30 attach/claim/release/close cycles, goroutines 3 → 3, 0 pairings parked, 0 ownerships left. attachTable.claim makes the TTL/dial‑back race exclusive so done closes once; splice drains both pumps before returning.
Lease keeper stopped exactly once Holds One heartbeat per splice, one splice per pairing, stopped by the hbCtx splice cancels on return. It can outlive finish by one bounded demotion; those writes land on a closed socket, and demoteTo(from, …) stops it contradicting a newer claim (M11, M12 killed).

Stress results (all -race, throwaway tests in scratchpad/probe/attachplane/zprobe_stress_test.go)

# Scenario Result
S1 10 attaches × 60 claims racing, 70 runs store reached gen 61 every run; 0 double‑attached control per generation; 0 over‑the‑store announcements; ≤1 settled controller matching the store every run; 5 of 70 runs showed a simultaneous‑belief overlap → F1. With the F1 fix: 0 of 45.
S2 claim during a peer's displacement (controller's sandbox acks 250 ms late) 12 runs: exactly one client believing control, matching plane and store
S3a/S3b peer stops reading during a claim / during an attach answered in 206 ms / 403 ms (bound 4×200 ms)
S4 socket close racing claim + release 40 interleavings × 12 repeats: every broker returned, 0 ownerships survived, no race reports
S5 sandbox acks late / never / acks the previous generation claim answered in 307 / 607 / 607 ms at a 300 ms timeout; one controller; taker told the store's generation; the previous‑generation ack correctly ignored
S6 heartbeat renewal refused during the attach's own claim 60 interleavings × 12 repeats: plane and store never disagreed about who holds control (demoteTo(from, gen))
S7 reconnect with a stale expected generation answered stale at the store's generation, never promoted
S8 snapshot held 8× the ack timeout by a slow client arrives; splice survives
S9 attach that has not sent its opening resize registered, displaced to view at the new generation, taker the only controller
S10 F1 reproduction 6/6, 1.70 s overlap at production defaults
S11 leak census, 30 cycles 3 → 3 goroutines, 0 parked pairings
S12/S12b F2 and F3 reproductions both confirmed

The PR's own suites are green at this head: attachplane, controlapp, internal/session, internal/relay, internal/attachio, protocol/..., control, cmd/rainier all pass -race -count=2; attachplane also passes -count=20. go build ./... and go vet ./... clean.

Mutation battery — 22 mutants, 20 caught, 2 survived

Caught by the PR's own tests: M1 serial fan‑out (TestTheFanOutReachesEveryPeerAtOnce) · M2 unbounded courtesy notice (4 tests) · M3 no wg.Wait() (3 tests) · M5 unbounded install write (suite hangs → timeout; genuine kill) · M6 no client write deadline (suite hangs; the production symptom) · M7 close on the caller's deadline (TestACourtesyNoticeNeverClosesAHealthyClient) · M8 announceAs trusts the caller's type (2 tests) · M9 no staleattached rewrite · M10b announce hold released before the write · M11 unconditional demote (2 tests) · M12 demoteTo off‑by‑one (4 tests) · M13 late peer registration (TestAnAttachStillReadingItsFirstMessageIsDisplacedLikeAnyOther) · M14 no generation give‑back · M15 controller's claim answered from memory · M16 advance walks backwards (2 tests) · M17 displaceTo split into two holds · M18 no stale‑ack drain · M19 plane trusts the client's stamp · M20 client control verbs forwarded · M21 finish reads mode and generation separately.

Survivors:

  • M4 — move displaceTo into the fan‑out goroutine. Survives the PR suite and my probes. Not a real gap: it weakens a defensive ordering ("every displaced peer stops being forwarded for at once, ahead of the fan‑out") whose guarantee is already carried by wg.Wait() and by the taker's own installAndWait having raised the session's controllerGen before displace runs. I could not construct an observable difference.
  • M10′ — state read outside the announce hold. Survives the PR suite; killed by S1. Real coverage gap on the property the announce mutex exists for → F6.

(M10 as first written was an inert twin and was replaced by M10′. M5/M6 kill by suite timeout rather than an assertion, which is the correct signal: without the deadline the write never returns.)

Round‑four fixes vs. the four pairings and the CLI — no regression

All green at -count=3 -race: new client + new plane + new sessiond (TestJourney1And2…, TestJourney3And4AClaimIsAnsweredOnlyAfterTheSandboxHasTheFence, internal/relay/TestAMidAttachHandoffIsAcknowledged); old client + new plane (TestAnUnnegotiatedAttachGetsTodaysMessageSetAndStampedFrames, TestALegacyAttachDisplacesANegotiatedControllerAndSaysSo, TestThePlaneStampsItsOwnViewOverTheClients); new client + old plane (internal/attachio/TestNewClientOldPlane, TestViewNeverTypesEvenWhenNothingAnswers, TestAnAttachThatAsksForNothingDialsExactlyAsBefore); new plane + old sessiond (TestAnOldSandboxNeverAcksAndTheHandoffStillHappens, internal/relay/TestAnOldPlanesAttachmentIsUnconditional, TestAnUnboundFrameIsTheBytesItAlwaysWas). CLI: --view/--take flags and their mutual exclusion, reconnect (TestJourney6ReconnectAsksForWhatItActuallyHad), info's Controller row including the older‑server case, [you have control] via TestControlWonInsideSomebodyElsesHandoffIsStillAnnounced and TestTheTakeKeyClaimsFromTheGenerationItWasTold, and TestGainingControlSaysHowBigThisTerminalIs. S9 independently confirms the early‑registration fix; S8 confirms the write‑deadline fix did not bound the snapshot.

Verdict

Converged. The evidence for convergence, as distinct from rounds 2–4: 20 of 22 mutants die to the PR's own tests, including every round‑four fix; the one surviving semantic mutant is a coverage gap rather than a live bug; 11 of 12 stress scenarios were clean on first write; and the single defect found is not a new path but one residual instance of the class round four named — reached through the decision whether to announce rather than through an announcement — traceable to one sentence of design reasoning whose premise the round‑four fix itself invalidated. That is a module whose defect surface has collapsed to a single identifiable seam, not one still generating new ones.

Merge after fixing F1 (three lines, fix verified above, plus S10 as the regression test). F2 and F3 are one‑liners worth taking in the same commit; F4–F7 are documentation, coverage and a comment and can follow. Reason: F1 is the one invariant the feature exists to protect, it reproduces deterministically at production defaults with a 1.7 s window in which two users' screens both say they have control, and leaving it means the next round finds it — which is exactly the pattern this review was asked to decide about.

A final fix session has been dispatched for F1–F7 and the Cloud re-pin; it will post dispositions here.

jiashuoz and others added 12 commits September 11, 2026 03:40
A fifth independent review ran a 22-mutant battery and twelve stress
scenarios through the real plane and found one residual instance of the
announcement class — reached through the decision whether to announce
rather than through an announcement — plus two one-liners and four small
items. This writes down what each is, what changes, and what was
considered and rejected.

Co-Authored-By: Claude <noreply@anthropic.com>
displace read `displaceTo` returning moved=false as "this peer is already
at gen, so it has already been told". Those are different facts. Two
paths move an attach's own state and then spend real time before
announcing it — demote, which holds installAndWait for a whole
acknowledgement timeout against a sandbox that never answers, and
sendStale, which can wait out a client's write budget — and a take-over
landing in either gap skipped that peer with `continue`, returned from
the fan-out at once, and told the taker it had control. The displaced
device was told nothing until its own wait timed out: 1.7s at the
production default, with two screens both saying "you have control".

`moved` now decides only whether that peer's SANDBOX needs a new
binding. The announcement goes out unconditionally, and it is safe
unconditionally because announceAs reads the mode and the generation
under the announce hold and reports what it read — so a peer already at
gen is told its own real state, and a client already there folds it to
no notice. That premise is the one the fourth round's fix created and
the design note it invalidated had predated.

The regression test is the reviewer's S10, and it fails on c6d16cb 5/5.

Co-Authored-By: Claude <noreply@anthropic.com>
Two findings, both one-liners, both about somebody not being told.

A claim whose binding never reached its sandbox gives the generation
back rather than becoming a phantom controller. It then told only the
client that asked. The store was two generations on with a vacant
holder, while the attach that had been the controller was still
`control` in the plane and still forwarded for until its own heartbeat
renewal was refused — up to one interval later — and every viewer's next
press was refused once too, about a session nobody was driving. The
give-back now fans the resulting generation out, with the number
sendStale already reads.

The runner pump forwarded `attached`, `stale` and `control_changed`
verbatim, so a sandbox could tell a client it has control. That client
prints [you have control], stops sending claims — a client that believes
it is the controller never claims — and types into a plane that drops
every frame, with detaching as its only way out. Those three are
announceAs's to send, exactly as `control` and `control_ack` are the
plane's to write, and the runner pump now drops them the way the client
pump drops the other two. Dropped rather than fatal: ending the attach
would hand a buggy sandbox a way to disconnect every client watching.

Co-Authored-By: Claude <noreply@anthropic.com>
`rainier attach --view` is documented as "watch without ever claiming
control". The attach was genuinely admitted a viewer and genuinely typed
nothing, but Ctrl-\ still sent a claim and the plane honoured it: a
view-mode attach whose principal may drive IS authorized to take control
mid-attach, because that is what a reconnecting controller admitted as a
viewer depends on, so the service cannot tell the two requests apart.

The flag is this user's instruction to their own client, so it is held
in the one place that decides what this client claims. That covers the
take-control key and --take's single claim alike — the CLI already
refuses the two flags together, so the second is belt and braces. The
key is swallowed rather than forwarded: a --view attach sends no input
at all, so forwarding it would reach the same nowhere with more moving
parts, and nothing is printed, which is what the key already does on a
device that has control.

Co-Authored-By: Claude <noreply@anthropic.com>
… hold

The write budget was one number for every frame, so it was a WHOLE-write
deadline: at 60s and a 16MiB read limit, the largest frame this stream
can carry demanded ≈273 KB/s sustained, and a client making perfectly
steady progress over a slower link was CloseNow'n mid-snapshot. It is
now a base plus the time the payload needs at the slowest rate this
stream will keep a client for — 64 KiB/s — so that frame gets 60s + 256s
and a message with no payload gets exactly the base, which is what the
budget is actually for: closing a socket that has taken nothing.

Both knobs move onto the stream. A package variable three tests write is
safe only for as long as nothing in the package calls t.Parallel().

And a test for the announce mutex under contention, which no test
created deliberately: the fifth review's one surviving mutant reads
(mode, gen) before acquiring the hold and reports that, which is the
literal shape of the defect the hold exists to prevent. It now dies.

Co-Authored-By: Claude <noreply@anthropic.com>
The previous commit made the take-control key inert by reading
`askedView`, which does not mean --view. It means "this attach is
requesting view mode", and cmd/rainier's reconnectOwnership asks for
exactly that on every reconnect whose previous attach ended as a viewer
— so a device superseded while its network was out does not take control
back on a blip. Those Options are otherwise byte-identical to --view's.

So a plain `rainier attach` that lost control, reconnected, and came
back a viewer lost Ctrl-\ for the rest of the process: silently, with no
line printed, and with no way back, because the plane promotes an attach
only in answer to a claim. The one way out was detaching and re-running
the command.

NeverClaim is set by the flag and by nothing else, and claim() reads it.
The regression test builds the Options a reconnect builds and presses
the key; it fails on the askedView version.

Found by the adversarial review of this round.

Co-Authored-By: Claude <noreply@anthropic.com>
…first word

Two defects the reviews of this round found in the two fixes before it.

The give-back fan-out did not wait. It is the only fan-out in the module
that can demote a peer which genuinely holds control — release and
finish both demote the caller first, so their peers are already viewers
— and displaceTo flips that peer to `view` in the plane before its
sandbox has the matching binding. Fire-and-forget there disarms the NEXT
taker: displaceTo hands it `was == view`, it skips installAndWait, and
it is answered with the old binding still on the wire. The pty's
generation fence still holds, so nothing executes twice; the ordering
this module promises does not. It waits now, like a take-over, and the
test asserts the binding has landed when the claim returns rather than
polling until it does.

And announcing to every peer reached attaches that are registered but
have not been told what they are yet — registration happens before the
first message is read, which is bounded only by attachFirstMsgTimeout.
A courtesy notice arriving first IS that client's opening answer, and a
client reads its first answer differently from a later one: a plain
viewer printed "[another device took control]" twice instead of
"[viewing — another device has control]" once, and a --view attach,
which should hear nothing when it opens, heard it too. A peer whose
state did not move AND which has been told nothing is left to its own
opening answer, which reads the same state and says the same thing. A
peer whose state moved is still told, because there it is news.

Also: the S10 regression test now runs with five seconds of
acknowledgement margin and fails a run that stalls past it, because a
displaced attach's OWN demotion emits a byte-identical notice when its
wait gives up.

Co-Authored-By: Claude <noreply@anthropic.com>
…everywhere

The write budget scaled on the payload, but Data is a []byte and JSON
carries it base64, so four wire bytes leave for every three of payload:
the largest frame demanded ~8% more throughput than the rate promises.
wireSize rounds the payload up to its encoded length.

The Send-level test that was supposed to pin the scaling passed with the
scaling reverted, because json.Marshal of a 2MiB payload under -race
costs most of a second — vacuous at exactly the -race -count the gates
run it under. It buys the same half second from a 384-byte payload at
1 KiB/s now, where marshalling is noise, and it has an upper bound as
well as a lower one. The largest-frame assertion beside it was a
tautology for any base; it checks the wire rate against the promised
floor.

Documentation the last five commits falsified, in the four places it
lives: the v0 contract said Ctrl-\ still works under --view, the
ownership note said that flag's key is answered `stale`, the help text
did not mention the key at all, and two prior design-doc sentences the
change invalidates now say in place what superseded them — which is the
thing this round's own finding was about, and which it had repeated.

And TestRunNoRaceOnFloodedOutputDuringDetach's five-second wait for the
flood to start failed about one run in four on an idle machine, at
c6d16cb as well as here. It is waiting for a precondition, not asserting
a throughput, so it waits generously.

Co-Authored-By: Claude <noreply@anthropic.com>
Both new --view tests pressed Ctrl-\ the moment the attach started.
Ctrl-\ is not this client's key until a plane has proved it speaks
conditional ownership, so a press that beat the opening `attached` was
forwarded as an ordinary byte and never reached the key handler: the
reconnect test then failed about one run in fifteen, and the --view test
would have passed on a press that asked nothing.

A --view attach prints nothing when it opens, so the snapshot behind the
answer is what says the answer has landed. Both tests wait for it, and
both still fail when their guard is reverted.

Co-Authored-By: Claude <noreply@anthropic.com>
… write

Reading the state inside the announce hold closed every interleaving with
another announcement; it did not close the one with a state change during
the write. A client that stopped draining for longer than a handoff's
deadline had the notice about its displacement give up on its hold, then
took the message already being written, which named the old state, and
nothing corrected it afterwards: a viewer gets no heartbeat, its stdin is
dropped at the plane, and a client that believes it controls sends no
claim. After the write, announceAs now re-reads, and if the state moved it
says so as control_changed, until the state has stopped moving. Bounded,
because the state only moves forward.

With it, the "not yet told" skip in displace asks only whether the peer has
been told anything. A peer granted control and displaced before it finished
opening was told "somebody took control from you" about control its client
never heard it had; its opening answer reads the state at send time and
names the moved state as attached view, which is the true first word. The
test that pinned the moved peer being told first pinned that symptom and
now pins the opening word.

Regression tests are the sixth review's probes, with their harness, and fail
on the previous head.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3ty8xDSnUhwAwnoEAHEFu
The opening viewer notice was suppressed on askedView, which a reconnect
also sets, so a plain attach superseded while its network was out came back
a viewer and printed nothing, against the help text and the CLI contract.
The fact that means "asked to watch" is neverClaim, which only --view sets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3ty8xDSnUhwAwnoEAHEFu
@jiashuoz

Copy link
Copy Markdown
Member Author

A sixth independent review of head 2fa15d0 (25 mutants, 20 caught; the full stress set clean, F1/F2/F3 reproductions passing). Three findings, fixed directly in c9a0a98, 2533edd and ef0e901 with the review's own probes as regression tests: an announcement now reports the state at the END of its write (re-read after the write, correct with control_changed until stable); a reconnected viewer is told it is viewing (neverClaim, not askedView); the not-yet-told skip in displace asks only whether the peer has been told, so a peer displaced before it finished opening gets attached view as its first word. The shipped test that pinned the moved peer being told first now pins the opening word. Verbatim review:

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.

1 participant