Commit Graph
2093 Commits
Author SHA1 Message Date
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 2d49262dfa Merge final terminal engine documentation
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 02:05:41 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block 9f57a718a4 docs(terminal): quote a payload big enough to reach the threshold it tests
The comment cited 1 MiB of RIS as evidence the tail high-water is zero. At
1 MiB the *deleted-pump* arm never reaches TAIL_CAP either, so the number
proved nothing: both worlds report "never full" and the measurement agrees
with whatever it was pointed at. 4 MiB is also short -- a 16 KiB read of RIS
defers 16382 bytes, not 16384, so it stops 512 bytes under the cap. 8 MiB is
where the counterfactual fires.

Also disambiguates the read count. 257 is reads *completed*; the 256 in the
review thread was a zero-based loop index, and a comment that doesn't say
which invites the next probe to disagree with it by one.

Found by Sami, in wording he had written and I had shipped -- I hit the same
defect on my own probe an hour earlier, fixed my run, and copied his number
across without noticing it had the flaw I'd just corrected.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 02:02:22 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf e1eaf88b0d docs(terminal): cite the discriminating tail payload
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 02:01:55 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf df19569e7c Merge final terminal backpressure documentation
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

* commit '232f56b67219613ddb1fd6628a73ae31bd1156a5':
  docs(terminal): say why tail_full cannot fire, not just that nothing calls it

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:59:18 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block 232f56b672 docs(terminal): say why tail_full cannot fire, not just that nothing calls it
The previous wording said the signal has no consumer. That is the weaker half
of the fact and the half that ages badly: a reader who deletes the pump loop
learns the rest by wedging a terminal.

Both measurements are now in the comment. With the loop, 1 MiB of pure RIS
through 16 KiB reads leaves a tail high-water of exactly 0 -- the tail is
empty between iterations, so the predicate is unreachable by construction.
Without it, the same stream reaches TAIL_CAP at read 257. Reproduced here
before quoting: 0 and 257 both hold on this tree.

Wording is Sami's, who measured it first and made the argument that the
signal and the loop are one fact from two sides. Delete the loop and this
predicate stops being unreachable in the same instant it starts being needed.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:56:59 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 73d00c6013 Merge terminal scheduler documentation
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

* commit '9ca661ee8e684d720724673b69c8f8a110fb72b2':
  docs(terminal): say that the tail-depth signals have no consumer yet
  docs(terminal): repoint the links the slice_bytes deletion broke

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

# Conflicts:
#	desktop/src-tauri/crates/buzz-terminal/src/fences.rs
#	desktop/src-tauri/crates/buzz-terminal/src/lib.rs
#	desktop/src-tauri/crates/buzz-terminal/src/reader.rs
2026-08-02 01:55:55 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 5f416f7d25 test(terminal): require the runtime to pump deferred work
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:53:12 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block 9ca661ee8e docs(terminal): say that the tail-depth signals have no consumer yet
`tail_full` and `tail_drained` are the queue bound, and nothing outside the
tests calls them: the runtime reader pumps `drain` to completion after every
read, so the tail cannot reach the cap and nobody needs to ask. That is fine
and it is the design -- the signal was built for the reader that stops
pumping -- but an exported, documented predicate with an empty call graph
reads as wired to anyone who doesn't go looking. Stated in the doc comment
instead, because an unused signal that looks connected is worse than one that
says it isn't.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:52:08 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block 651ef710b4 docs(terminal): repoint the links the slice_bytes deletion broke
Two intra-doc links still resolved to the deleted function, so `cargo doc`
emitted unresolved-link warnings. Neither `cargo test` nor clippy sees those,
which is why they survived a green gate -- the deletion was verified by the
checks that run in CI and not by the one that reads the docs.

The single remaining mention is deliberate: the paragraph in `fences.rs`
explaining why the old function existed and why it is gone.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:48:49 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 3d4fc49df9 Merge terminal scheduler fixture correction
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

* commit 'd67b99a3dd611f0c239d9bce13552417acaba622':
  refactor(terminal): delete the slice-sizing function nothing calls

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:47:32 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf fe7d291f48 test(terminal): reject stop before child reap
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:43:11 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block d67b99a3dd refactor(terminal): delete the slice-sizing function nothing calls
`slice_bytes(columns, lines, scrollback)` stopped being the scheduler's
function when slices became remaining-aware, and the fixtures went on
asserting against it. The two disagreed exactly where the old floor bound --
reporting 4 bytes where `drain` used 1 -- so the preconditions guarding the
decrease arm were describing behaviour that no longer existed. Not wrong at
today's geometries, and no test would have noticed when it became wrong.

It had zero callers outside the tests it misled. Deleted, with every
assertion moved to `slice_bytes_remaining`, which is what the engine calls.
One function, one answer.

`MIN_SLICE` goes with it: the floor is 1 and lives in the function, because
on a grid whose worst atom exceeds the whole budget no wider slice can
promise to stop after the callback that crosses. A named constant that only
appeared in a doc comment is a second authority waiting to disagree with the
first.

69 tests, debug and release. Test-only in effect; the deleted item was
unreachable from the engine.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:42:44 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 7a75b4e170 Merge terminal fixture repair
* commit '84b274d3c5da05257df95953b3f5115ead5420dc':
  test(terminal): make the decrease and RIS arms assert what they claim

Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:38:21 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block 84b274d3c5 test(terminal): make the decrease and RIS arms assert what they claim
Test-only. Five gaps, four of them found independently by two reviewers,
which is the part worth noting: they are the kind that pass.

The decrease arm captured `first_pending` and never asserted it, compared
`first_units` to the literal `12` rather than the control's own field, and ran
its geometry-persistence check on a *different* terminal -- one that had only
ever grown. That last one is the sharp one. It would pass an implementation
that retained the debt on shrink and dropped it on the next geometry resize,
which is a real shape: `Feeder::resize` sees every resize, and only the
scrollback branch is conditional. Verified by writing that mutant: the arm now
fails `left: 80000, right: 4040000`, and it did not before.

Two things had to be true for it to bite, and it was inert without either. It
runs on the terminal that actually went shallow -> deep -> shallow, and the
resize carries the *shallow* depth -- handing the debt's own value back in
makes `max(debt, new)` and a plain assignment agree, so the arm cannot tell
them apart. I found that second one by writing the mutant, watching it
survive, and looking again.

The RIS table collected all six arms before asserting but then asserted them
in a loop, so a failure still printed one. Collecting stops an arm being
skipped; comparing the vectors is what stops the failure being truncated. With
the history term deleted the failure now shows all six rows and which ones
moved, rather than the first mismatch.

Every comparison in the decrease arm is now against the control's own field.
A constant or geometry change has to move both sides together, or the fixture
quietly starts asserting the arithmetic of the day it was written.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:37:44 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 03499c956e Merge Dawn's terminal scheduler repair
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:32:40 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block e1500806d3 fix(terminal): retain the scrollback debt a shrink does not immediately repay
The previous commit lowered the feeder's scrollback on a decrease, which reads
as correct and is not: upstream frees history lazily. `Storage::shrink_lines`
truncates only once the buffer exceeds the new length by `MAX_CACHE_SIZE`, so
immediately after a shrink the grid still owns rows a reset has to walk.
Pricing at the new depth charges for a grid that does not exist yet.

So the feeder tracks a high-water mark instead of the current depth. It never
falls, needs no clearing transition, and cannot go stale in the unsafe
direction. The cost is real and stated: a session that shrinks its scrollback
keeps paying the deep price for the rest of its life. The alternative is a
bound that is wrong immediately after every shrink.

The fixture that permitted this is the more important half. It asserted
`shrunk_work >= fresh_shallow`, which a feeder that dropped the debt satisfies
*by equality* -- "never had a debt" and "dropped the debt" are the same state,
so any predicate anchored to a fresh control blesses both. Strictness on the
pricing field is what separates them, and the pricing field is the only one
that can: the scheduling observables separate only when the two depths land on
different slice widths, and both are usually floored. The arm now asserts
`completed_work > fresh_shallow` strictly, with the scheduling fields carrying
their per-field directions -- `first_units` inverts, because a narrower slice
retires fewer atoms per un-preemptable drain, which is the fence working.

Also here, both from the same gate:

* Monotonicity is swept per axis. A truncating `columns * lines` is a
  non-monotone middle on the columns axis alone, and a scrollback-only sweep
  cannot see it.
* Seventeen more rows in the pricing table -- `scroll_down`, `insert_lines`,
  `put_tab`, forward tabs, both `clear_line` modes, both `clear_screen` modes,
  and the parameter extremes for each. The four that were missing are the four
  whose weights share a shape with ones already covered, which is exactly the
  argument for covering them separately rather than by analogy.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:30:26 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block 1a2c86ebed fix(terminal): repair three defects the gate found in the work-bounded seam
All three were pre-registered predictions that the fixtures at a957d25b0 did
not cover, and each was independently reproduced before being fixed.

**Geometry ownership.** `Terminal::resize` took a whole `Size` and forwarded
two of its three fields to the feeder, so a scrollback change updated the
`Term` while the feeder kept charging its construction depth. That is most of
RIS's price -- 3,840 charged against 803,840 honest at 80x24 -- and because
`slice_bytes` is derived from the densest atom, it also left the *scheduler*
sized for the wrong grid: a stale 130-byte slice admits 65 RIS atoms where the
honest 4-byte slice admits two. The split was permanent; later resizes tracked
their own fields and never re-derived the third. `Feeder::resize` now takes
the whole `Size`, so there is one ownership boundary and no way to update two
of three.

A bound derived at construction from a value that can change afterwards is a
bound with an expiry date.

**Post-atom overrun.** Slices were sized against the whole budget, so a slice
could hold an oversized atom *and* the callbacks behind it: `ESC c` followed
by `Xmore` ran three callbacks where the law permits one. Width is now a
function of what *remains* of the budget, which is a single byte once an atom
worth more than the budget is in play. Two refinements the fixtures forced:
plain runs are metered by the plain-byte cost and stop at the next `ESC`
rather than being priced as though every byte might be RIS -- pricing them
that way is correct and costs 181 MB/s -> 69 MB/s for a bound on something
that cannot happen -- and the escape scan is capped at `MAX_SLICE`, because
searching the whole tail per slice is quadratic and measured as a 7x
regression, a bound costing more than the thing it bounds. A `mid_escape` flag
keeps a sequence split across slices on escape metering; without it the byte
after a lone `ESC` looks like text and the atom rides into a wide slice.

**Saturating arithmetic.** `columns * lines` was computed in `usize` before
the cast, and `Size` is unclamped with no caller bounding it. Debug panicked
inside the accounting path -- reachable from `drain`, which is the pump a
paused reader must keep calling -- and release wrapped. The wrap is the worse
half: it does not degrade the fence, it inverts it, because `slice_bytes`
divides the budget by the atom cost, so an undercharged atom yields a *wider*
slice exactly when the atom is most expensive. Convert first, saturate
throughout.

The fixtures are where most of the work went, because three of them had to be
rewritten after the obvious version passed on the broken tree:

* Resize is asserted by equality with a terminal *constructed* at the target
  depth, across charge and acquisition count -- stronger than any threshold
  and immune to the constants moving, since both arms move together. A sanity
  arm proves the comparison is deterministic first.
* Shrinking is asserted *directionally* after establishing a debt
  (shallow -> deep -> shallow). Equality would forbid conservative retention,
  and resizing straight down leaves a feeder indistinguishable from one built
  shallow -- which is also the signature of a feeder that never updated.
* `completed_units` is asserted but is explicitly *not* the discriminator: it
  reads the same in both arms, because the same callbacks run either way.
* Saturation asserts the direction -- widest atom, narrowest slice -- plus
  monotonicity across nine decades. "Does not panic" is satisfied by the
  wrapping version, which is the version that inverts the fence.
* Preconditions are asserted rather than assumed: the geometry must let the
  scheduling fields separate at all, and the payload must be RIS, the only
  escape reaching the only weight with a scrollback term.

24 mutants dead in debug and release, including one per repair.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 01:26:26 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 04e9d0dc7e fix(terminal): raw-drain while child exits
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-02 01:00:39 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 1a06fffddc Merge commit 'a957d25b02881bad31bb0d6ab6c9571545ba9bc8' into mari/tui-integration
* commit 'a957d25b02881bad31bb0d6ab6c9571545ba9bc8':
  feat(terminal): bound the lock hold by weighted work, not by bytes
  test(terminal): close the review gaps in the cluster and snapshot contracts
  feat(terminal): give an attaching subscriber the screen as it stands
  feat(terminal): give the renderer each cluster's true column

Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>

# Conflicts:
#	desktop/src-tauri/crates/buzz-terminal/src/damage.rs
#	desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs
#	desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs
2026-08-02 00:53:06 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block a957d25b02 feat(terminal): bound the lock hold by weighted work, not by bytes
Both existing fences meter bytes. That is right for memory and wrong for
time: `ESC[m` and `ESC#8` are four bytes each, and the second rewrites every
cell. A DECALN flood therefore reaches p95 65535us against a 4000us budget
with **zero** F1 aborts -- the fence never fires, because nothing is
buffered. Unfenced, one acquisition was observed at 22.1s.

So add a third quantity. `Counting` wraps the handler and charges each
callback what it touches: an O(cells) callback costs `columns * lines`, an
O(1) callback costs 1. `Feeder` accepts bytes into a pending tail and parses
only what one work budget affords, leaving the rest for the next call. That
bounds the hold. It does not bound the queue, so the tail has a cap and a
loud breach counter, and the reader is told to stop reading via a depth query
rather than a pause flag the fence owns -- a flag is a state a reader can fail
to clear, which is how a paused reader strands a child mid-teardown.

The weights are read from `alacritty_terminal-0.26.0/src/term/mod.rs` and then
checked against measurement, never fitted. Where source and measurement
disagree the source wins and the slack is recorded in the table. Three
findings from that reading are worth naming:

* `delete_chars`/`insert_blank` cost `columns` for *every* N and are worst at
  N=1 -- the swap loop runs `columns - end` times, so cost falls as N rises.
  A sweep that varies only N reports them parameter-dependent *decreasing*,
  and the honest reading of that invites charging by N, which is backwards.
* `move_backward_tabs` (CBT) assigns `col` inside the `if self.tabs[i]` test,
  so with tabstops cleared the cursor never moves, its `col == 0` exit is
  unreachable, and all N iterations rescan the row: `ESC[3g ESC[65535Z` is
  eight bytes for 82ms at 1600 columns. Its twin `move_forward_tabs` assigns
  outside the test and is fine. The wrapper stops it at the first fixed point
  -- permanent, because the scan depends only on the cursor -- which makes it
  O(columns) and, verified exhaustively over every tabstop subset of a
  12-column grid, lands on the same column as the naive loop.
* `reset_state` (RIS) resets both grids and walks the primary's scrollback, so
  two bytes can be worth more than the whole budget. Priced on *configured*
  depth, not `history_size()`, which observes only the active grid: a filled
  primary followed by `ESC[?1049h` reads as empty while the work is still paid.

Slice size is derived from the densest atom the grid admits rather than
chosen, so the bound holds on the first byte of a cold feeder. An earlier
version sized slices from observed density and was strictly worse where it
matters -- a fresh feeder has seen nothing, so its first slice is wide.

`Terminal::feed` now returns whether a tail remains and the caller pumps
`drain` between lock acquisitions. `feed_fully` is the whole-buffer form,
deliberately a separate name because it reinstates the unbounded hold.

Every fixture asserts exact counts, never `> 0` and never `<=` alone. Both
directions are load-bearing: with the CBT break deleted the callback loops
65535 times while charging `work == 1`, so an upper bound passes over it, and
the mutant lands on the same cursor column as the fix, so a semantic assertion
passes too. 20 mutants dead in debug and release.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-02 00:46:05 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf 4bd3b03a3c fix(terminal): recover attach publication race
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-01 22:11:47 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block e887bdb2c4 test(terminal): close the review gaps in the cluster and snapshot contracts
Six review findings, all coverage on code that was already correct, plus one
enforcement change.

Clusters. The join guard has two halves and only one was exercised: every
fixture built a plain cluster after a marked one, none the reverse. Drop
`joinable` and a release build silently emits `Span { text: "xye\u{301}",
cluster_count: 3 }` -- four chars counted as three, so the consumer misplaces
everything after it. Also fixture the `u16::MAX` join refusal, which is live
rather than defensive: `Size.columns` is an unclamped `usize` with no caller
bounding it, so a 70000-column row reaches it and must split rather than wrap.

Promote the encoder's `debug_assert` to `assert!`. This is a wire invariant,
and a check that vanishes in release vanishes exactly where the corruption
ships: with the defect present, the promoted build fails at the producer
rather than leaving an undecodable span for the renderer to misdraw. Measured
at +5.9us on a 65us encode for 80x24 -- 0.04% of a 60Hz frame.

Snapshot. The four fixtures asserted the damage plane and nothing else, so a
capture that dropped the screen's last row, lied about the cursor, or stamped
a stale grid identity passed all of them. `visible_text` trims and filters
empty lines, which is what let the missing row disappear -- a helper written
for legibility concealing the defect it should have exposed. Assert the row
set rather than the text, the cursor against a real position and DECTCEM, the
whole `Viewport` rather than `columns` alone, and that a full-grid copy is
billed to the renderer's meter and not the reader's.

Findings from Sami's P2a and P2b reviews; enforcement call from Eva; encode
cost measured by Sami.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 22:02:32 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf ba4ff04fea feat(desktop): wire terminal sessions to Tauri
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-01 21:52:32 -04:00
1a213fe709 feat(terminal): give an attaching subscriber the screen as it stands
A subscriber that arrives mid-stream cannot start from `render()`. Damage
describes what changed since someone last looked, so a newcomer is handed
whatever happens next -- on a quiet terminal, the cursor's line alone --
painted onto a blank screen. Upstream's `mark_fully_damaged` is private, so
an embedder cannot ask for a full frame that way either.

`snapshot()` copies the whole visible viewport instead, marks the frame
full so a reused encoder's dedup hashes realign, and stamps the geometry it
was captured under.

The delicate part is what it does not do. Damage is a single shared cursor
across every subscriber, so a snapshot that consumed it would steal the
incumbent renderer's pending rows: the newcomer's full frame would look
perfect while the established renderer silently froze. `capture_all`
therefore never calls `damage()` or `reset_damage()`, and their absence is
the mechanism.

Proving that needed more care than the interleaving alone. Because
`Term::damage()` marks the cursor line on every call, an incumbent owed
only the row it sits on gets that row back even when its damage was
stolen -- so the first version of the fixture passed against a deliberately
naive implementation. The committed fixture rewrites row 0 and parks the
cursor on row 3, putting the owed row where the cursor cannot return it for
free; it fails when snapshot consumes damage, and again when snapshot
copies damaged rows only or omits the full flag.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:44:36 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block 3a5c7d576e feat(terminal): give an attaching subscriber the screen as it stands
A subscriber that arrives mid-stream cannot start from `render()`. Damage
describes what changed since someone last looked, so a newcomer is handed
whatever happens next -- on a quiet terminal, the cursor's line alone --
painted onto a blank screen. Upstream's `mark_fully_damaged` is private, so
an embedder cannot ask for a full frame that way either.

`snapshot()` copies the whole visible viewport instead, marks the frame
full so a reused encoder's dedup hashes realign, and stamps the geometry it
was captured under.

The delicate part is what it does not do. Damage is a single shared cursor
across every subscriber, so a snapshot that consumed it would steal the
incumbent renderer's pending rows: the newcomer's full frame would look
perfect while the established renderer silently froze. `capture_all`
therefore never calls `damage()` or `reset_damage()`, and their absence is
the mechanism.

Proving that needed more care than the interleaving alone. Because
`Term::damage()` marks the cursor line on every call, an incumbent owed
only the row it sits on gets that row back even when its damage was
stolen -- so the first version of the fixture passed against a deliberately
naive implementation. The committed fixture rewrites row 0 and parks the
cursor on row 3, putting the owed row where the cursor cannot return it for
free; it fails when snapshot consumes damage, and again when snapshot
copies damaged rows only or omits the full flag.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:43:48 -04:00
f3e7eb16c9 feat(terminal): give the renderer each cluster's true column
The span encoding shipped a concatenated string and a start column, which
left the consumer to recover cell boundaries from the text. It cannot: a
regional-indicator flag is two one-column cells and must split per
codepoint, while a keycap is one cell holding three codepoints and must
split per grapheme. Those rules are opposite, and the distinction lives in
the grid, not in the string. Any split rule over the concatenated text is
wrong for one of the two.

So the span carries what the grid knows. `width` is the columns each
cluster advances, uniform across a run by construction; `cluster_count`
says how many clusters the text holds. Decoding is then arithmetic with no
Unicode table anywhere: a count of 1 means the whole text is one cluster,
otherwise cluster i is the i-th char at `column + i * width`.

Geometry flags are also masked out of the style key. `WIDE_CHAR` and
`WRAPLINE` are grid bookkeeping, not appearance, and while they sat in the
key they broke runs as a side effect -- hiding the explicit width check
behind a coincidence, and splitting the last column off every wrapped row
into a span of its own. Row dedup still hashes the full flags, so nothing
it needs to see is lost.

Eight fixtures cover the contract, including the flag/keycap pair whose
opposing rules make the case. Six mutations were run against them and each
fails independently: forcing width to 1, dropping the width comparison,
never breaking on zerowidth marks, removing WRAPLINE from the mask,
removing the mask entirely, and never incrementing cluster_count.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:34:04 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andtlongwell-block e78491767b feat(terminal): give the renderer each cluster's true column
The span encoding shipped a concatenated string and a start column, which
left the consumer to recover cell boundaries from the text. It cannot: a
regional-indicator flag is two one-column cells and must split per
codepoint, while a keycap is one cell holding three codepoints and must
split per grapheme. Those rules are opposite, and the distinction lives in
the grid, not in the string. Any split rule over the concatenated text is
wrong for one of the two.

So the span carries what the grid knows. `width` is the columns each
cluster advances, uniform across a run by construction; `cluster_count`
says how many clusters the text holds. Decoding is then arithmetic with no
Unicode table anywhere: a count of 1 means the whole text is one cluster,
otherwise cluster i is the i-th char at `column + i * width`.

Geometry flags are also masked out of the style key. `WIDE_CHAR` and
`WRAPLINE` are grid bookkeeping, not appearance, and while they sat in the
key they broke runs as a side effect -- hiding the explicit width check
behind a coincidence, and splitting the last column off every wrapped row
into a span of its own. Row dedup still hashes the full flags, so nothing
it needs to see is lost.

Eight fixtures cover the contract, including the flag/keycap pair whose
opposing rules make the case. Six mutations were run against them and each
fails independently: forcing width to 1, dropping the width comparison,
never breaking on zerowidth marks, removing WRAPLINE from the mask,
removing the mask entirely, and never incrementing cluster_count.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:33:34 -04:00
c17f2d2c3d terminal: process-group lifecycle with drain-ordered teardown
Closing a tab must end the work the tab was doing. The child is a session
leader (portable-pty calls setsid), so signalling its pid reaches the shell
and leaves everything the shell started running, reparented to init and
holding the pty slave. Signal the process group instead: SIGTERM, a bounded
grace, then SIGKILL to the group whether or not the leader went quietly --
a polite leader does not imply an empty group.

Three things this found that reading could not.

waitpid(WNOHANG | WNOWAIT) returns EINVAL on Darwin. POSIX defines WNOWAIT
only for waitid; Linux tolerates it, macOS does not. The failure was silent
because `seen == pid` is false for -1 exactly as it is false for "still
running" -- error and negative result collapsed into one branch. Every
shutdown escalated to SIGKILL and the polite arm was dead code on the
platform we develop on. Only an assertion on *which arm fired* could see it.

A grandchild test that does not defeat the tty hangup proves nothing.
Killing a session leader makes the kernel SIGHUP the foreground group, so
the grandchild dies either way and a pid-only mutation survives. The fixture
must ignore SIGHUP and busy-loop; then pid-only leaks and kill(-pgid) does
not.

A child blocked writing to an undrained master does not die promptly even
on SIGKILL -- measured 606ms to reap, versus microseconds when drained.
So the reader outlives termination and reap. shutdown_draining takes the
reader by value, which makes the forbidden order a compile error rather
than a comment: a joined reader has been consumed and cannot be passed.

Mutation matrix, sources restored byte-identically after each:

  L1 delete SIGKILL escalation  -> FAIL (orphan + grace arms)
  L2 kill(pid) not kill(-pgid)  -> FAIL (orphan arm)
  L3 delete pre-reap try_wait   -> FAIL (Killed, want AlreadyExited)
  L4 join reader before SIGTERM -> FAIL (10.01s, grace is 250ms)

Fixtures built to ignore signals carry their own SIGKILL deadline. Without
it a legitimate failure leaves a core spinning at PPID 1, and L1/L4 were
detected by hanging -- a test that finds a bug by never finishing cannot be
told from a broken one. The deadline sits far outside the observation
window so it can never rescue a failing implementation.

2088 desktop + 3 mixer + 26 buzz-terminal + 5 fences + 4 resize green,
clippy --workspace --all-targets -D warnings clean, fmt clean.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:20:42 -04:00
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf f05ef3170f feat(desktop): enforce terminal frame publication credit
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 21:06:20 -04:00
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57ccandtlongwell-block c3c705583b feat(desktop): fence the terminal child's environment, PATH, and shell
A PTY child inherits its parent's environment by default, and Buzz's own
process holds the user's nsec. `CommandBuilder::new()` pre-seeds its env map
from `std::env::vars_os()` (`cmdbuilder.rs:218` -> `:74`), so the naive spawn
hands a live shell 70 variables including `BUZZ_PRIVATE_KEY`: press the toggle,
type `env`, read the signing key off the screen. Verified against a real PTY,
not inferred.

Three fences, each with the failure it exists to prevent:

* Environment: `env_clear()` first, then rebuild from an allowlist. Order is
  load-bearing and the reverse fails silently -- clearing after populating
  yields an empty environment and no error -- so the gate asserts both
  directions. Allowlist rather than denylist because a denylist is only as
  current as the last person who remembered to extend it.

* PATH: constructed, never inherited, never filtered. Buzz runs under Hermit
  activation, so an inherited PATH makes the user's `cargo` our pinned build
  toolchain -- on Linux, where no `path_helper` reorders it, at the front. The
  test seeds a uniquely-named executable into the parent PATH and proves the
  child cannot resolve it, rather than comparing PATH strings.

* Shell: `$SHELL` -> passwd -> `/bin/sh`, each candidate validated as an
  executable regular file. `access(X_OK)` alone accepts a directory, and a
  `$SHELL` of `/tmp` produces a child that aborts with a Rust runtime panic
  while `get_shell()`, `spawn_command()`, and every layer above report success.
  Raw mode bits alone accept a file the caller cannot execute. The conjunction
  is the check. The resolved shell is injected as `SHELL`, not inherited: those
  differ in exactly the cases the fallback chain exists for.

GUI context crosses a trust boundary. A channel name is attacker-controlled and
lands in a variable shells interpolate into prompts, so `BUZZ_CHANNEL` is
validated against a conservative character class and, on rejection, replaced by
the channel UUID rather than stripped -- a stripped `$(evil)` becomes `evil`,
which looks like a real channel.

Nineteen fixtures, each shown failing under the mutation it exists to catch:
deleting `env_clear`, reordering it to the end, inheriting PATH, denylisting
instead of allowlisting, dropping either half of the executability predicate,
inheriting `SHELL`, sniffing the shell's name for login flags, removing the
passwd candidate, weakening the shared validator, skipping channel-name
validation, stripping instead of substituting, dropping the length cap,
letting the display name reach `BUZZ_CHANNEL_ID`, emitting an empty
`BUZZ_THREAD_ID`, accepting `=` in a key, and adding a credential to the
desktop crate's reserved list without covering it here.

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:43:26 -04:00
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6andDawn 54cd502009 docs(desktop): record why G3's max bound cannot be widened
The p95 comment claimed 31x of headroom "lets this run on a loaded CI
runner," and read as if it justified the whole gate. It justifies the
quantile only. A p95 discards its worst samples by construction and so
degrades gracefully under load; a max over 200 samples is one
observation that any single scheduler preemption exceeds. No budget
makes it robust, because that tail belongs to the scheduler.

Measured on one 16-core host at the same commit: pass at load ~6, fail
at ~31 (p95 65535 us, max 164889 us). Sami measured a run where p95 sat
4x inside budget while max alone blew at 38150 us -- a p95-only gate
would have called a 38 ms stall green.

That asymmetry is what makes the ignore-plus-native-release placement
load-bearing rather than tidy, and it is the reason the repair for a
flake here is to fix the host, never to raise the number: a hitch is a
max-event, so widening the max is the one change that removes the only
assert catching the user-visible failure.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:41:43 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehrandtlongwell-block 25fcb5b26d Merge branch 'dawn/tui-engine-core' of ../buzz-tui-dawn into tui-substrate-perci
* 'dawn/tui-engine-core' of ../buzz-tui-dawn:
  feat(desktop): make a frame's viewport comparable in one value

Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:15:26 -04:00
tlongwell-blockandDawn 105aadb22c feat(desktop): make a frame's viewport comparable in one value
The integration lane's core operation is "does this frame describe the grid I
am currently showing?" -- a frame captured before a resize can arrive after it,
because frame delivery and the resize call travel separate paths. Answering
that meant comparing three loose fields, and a consumer that checks two of the
three is wrong on exactly the resize that changed the one it skipped.

Group them into `Viewport { generation, columns, screen_lines }` so the
question is one equality, and return it from `resize` rather than making the
caller ask afterwards: across a transport a follow-up query races the next
resize, so its answer can describe a grid that has already been replaced. A
same-size resize stays inert and reports the unchanged generation.

Two mutants, both caught: returning the requested viewport instead of the
applied one, and stamping a frame at encode time instead of capture time.

The stamping fixture was tautological on its first draft -- it compared a
frame's viewport against a copy taken from that same frame, which the language
already guarantees. It now asserts capture-time values explicitly, and the
comment records why the obvious form proves nothing.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:12:36 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehrandtlongwell-block 59648f0706 test(desktop): harden terminal engine gates
Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:10:30 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehrandtlongwell-block 1768d3c435 ci(desktop): lint nested terminal workspace
Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:08:47 -04:00
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehrandtlongwell-block 0f5e6816ca ci(desktop): cover nested terminal workspace
Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:08:47 -04:00
tlongwell-blockandDawn a094adce9a feat(desktop): add the resize seam to buzz-terminal
The engine had no resize entry point, so the integration lane was about to
build a coalescing protocol against a `Frame` that could not say which
viewport it described. A consumer could only infer that from message
ordering, which is exactly the thing a resize in flight breaks.

`Terminal::resize` takes one coalesced target size, never a stream: resize is
superlinear in scrollback (2.5-4.7 ms for a single column change at 10k
history) and runs while holding the terminal. Frames now carry `generation`,
`columns` and `screen_lines`, so a full frame is self-describing and a grid
discontinuity is detectable from the frame rather than assumed from ordering.

Three fixtures, each shown failing under its mutation. One of them only
exists because the first version of this test suite was vacuous:

Deleting the encoder's dedup-cache clear left all the resize tests green.
The width-changing fixture cannot catch it -- changing the width changes
every cell, so the hashes differ and the rows are republished for the wrong
reason. A height-only resize is the discriminator: the width is unchanged, so
reflowed rows hash exactly as before and a stale cache suppresses them right
after the consumer was told to discard what it had. Growing 10 -> 20 lines
moves row 0 to row 1, and under the mutation the frame carries 13 of 20 rows
-- seven rows blank on screen. With that arm added the mutant dies.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 20:07:49 -04:00
tlongwell-blockandDawn 7e7731b89b feat(desktop): add buzz-terminal engine crate
Scaffolds the terminal emulator behind the Buzz substrate: grid state, the
parser and its two hardening fences, the closed listener set, and the damage
encoding the renderer will consume. No PTY, no child process, no window --
those belong to the embedder, so this crate stays testable against byte
fixtures on every platform.

Three things ship together because each is load-bearing for the others:

* The crate itself.

* The explicit `members` key in the workspace manifest. With a bare
  `[workspace]`, `cargo test/check --workspace` expands to a set that
  excludes this crate, and its gates pass green-and-empty over a real
  defect. Verified as a four-arm matrix on this repo.

* A file-size ratchet rule for `src-tauri/crates`. The ratchet's only Rust
  root was `src-tauri/src`, so the crate would be born outside the repo's
  one size discipline -- silently, since the check exits 0. Verified: a
  1500-line file under `crates/` passes without the rule and fails with it,
  while the real crate passes.

Two fences, both enforced on byte counts rather than a clock, because a
clock makes the bound depend on machine speed:

* F1 aborts a synchronized update once it buffers 64 KiB.
* F2 rebuilds the parser once 256 KiB of parser-visible bytes are charged.

F1 turned out to be dual-purpose. Measured under a flood, the reader's lock
hold is p50 1 us while the renderer's *wait* for the same lock is p50
4245 us -- so the plan's original "reader hold under one frame" requirement
passes trivially while the window is visibly stuck. The gate is now the
renderer's acquire latency, and F1 is what holds it down: p95 127 us with
the fence, 16383 us without.

That gate also needs a fair lock. `std::sync::Mutex` lets the read loop
barge, and the renderer completed 12 frames to the reader's 39893
acquisitions; `FairMutex` under the same flood gives 76 and 63 us.

Six fixtures, each shown failing under the mutation it exists to catch:
deleting F1, deleting F2, omitting flush accounting, charging raw input,
and swapping the fair lock for a barging one.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
2026-08-01 19:56:44 -04:00
ac4fa13b8e perf(relay): serve relay-membership checks from the read replica (#4124)
## Summary

Route `Db::is_relay_member` — the membership check that runs on every
authenticated HTTP request and WS AUTH — through the standard
`route_read` machinery on the bounded arm, instead of adding a bespoke
cache (replaces #3844).

- `crates/buzz-db/src/relay_members.rs`: add `is_relay_member_on(&mut
PgConnection, ...)` executor seam; the pool version delegates to it.
- `crates/buzz-db/src/lib.rs`: `Db::is_relay_member` now routes via
`route_read("relay_membership", RoutePredicate::Bounded)` — replica only
on a proved fresh session, writer on any route rejection, writer re-run
on replica query error. Exactly the shape of every other routed read.

This is the one permission read served from the replica, by explicit
product decision (Tyler accepted ≤1s bounded staleness on reads we
choose): the fleet-wide fence guarantee (`BUZZ_REPLICA_READ_MAX_AGE_MS`,
deploy target 1s) is an order of magnitude tighter than the 10s TTL
proposed in #3844 and needs no invalidation machinery. Staleness is
symmetric for admits and revokes. `BUZZ_REPLICA_READ_MAX_AGE_MS` unset =
writer-only = kill switch. It is not precedent for routing other
permission reads.

## Validation

At this exact commit (`git rev-parse HEAD` confirmed in the same shell,
rustc 1.95):

- `cargo test -p buzz-db` — 94 passed, 0 failed
- PG-gated suite single-threaded — **151 passed, 2 failed**; the 2
failures are the per-owner-limit tests broken on main by #3829 (limit
3→5, tests still seed 3) — they fail identically at base `19d57b0d4` in
a pristine control checkout; separate trivial fix to follow
- New PG-gated test `is_relay_member_is_bounded_routed_and_fails_closed`
— divergent writer/replica fixtures prove: budget unset ⇒ writer; budget
set + fresh proof ⇒ replica; over-budget entry ⇒ writer
- clippy `-D warnings` + fmt clean; pre-push hooks green (desktop
check/test, rust tests, tauri checks)
- **Live-local pass** (TESTING.md, release binary,
`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`, fresh DB):
- writer-only (no `READ_DATABASE_URL`): member accepted, outsider 403
`relay_membership_required`; metrics
`route_decision{path="relay_membership",decision="writer",reason="disabled"}`
- replica configured + `BUZZ_REPLICA_READ_MAX_AGE_MS=1000`: member
accepted / outsider denied via `decision="replica",reason="fresh"`;
admit visible to the routed check within ~1.2s; revoke enforced within
~1.2s
- reader outage mid-flight (TCP proxy killed): member send still
succeeds in <200ms via
`decision="writer",reason="reader_acquire_timeout"`; outsider still
denied — fails closed, no availability loss

Reviewed by Wren: 9/10 minimalness, 9/10 elegance, 9.5/10 correctness at
this SHA.

Supersedes the 10s-cache approach in PR 3844, which should be closed
unmerged once this lands.

Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-08-01 13:32:16 -04:00
9d6726e5b3 chore(deps): bump nostr-relay-pool for RUSTSEC-2026-0224 (#4139)
Bump `nostr-relay-pool` from 0.44.1 to 0.44.2 to clear
[RUSTSEC-2026-0224](https://rustsec.org/advisories/RUSTSEC-2026-0224),
which addresses verification-cache poisoning that could let forged Nostr
events bypass signature validation on redelivery.

The dependency is transitive through `nostr-sdk`; this PR updates only
the corresponding package version and checksum in `Cargo.lock`. The
advisory currently marks every open PR red until this fix merges.

- `cargo test -p buzz-sdk -p buzz-cli` passes: 271 + 241 tests
- `cargo deny check advisories` passes
- `just fmt-check` passes

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-08-01 17:04:05 +00:00
756dd7f65d docs(nostr): document #h requirement for live reaction subscriptions (#3487)
## What this fixes

`fan_out_scoped` (`crates/buzz-relay/src/subscription.rs:278-394`)
enforces a deliberate, symmetric scoping invariant — documented in the
code itself:

> Global subscriptions (channel_id = None) do NOT receive channel-scoped
events. Channel-scoped subscriptions do NOT receive global events.

The relay derives a reaction's stored channel from its `#e` target at
ingest — client-supplied `#h` is ignored for channel determination
(`NOSTR.md:50` documents this for *writing*). The consequence for
*reading* is that every reaction is a channel-scoped event, so a live
subscription `{"kinds":[7]}` without `#h` is a global subscription and
**silently receives no reactions at all** — no error, no CLOSED, just
nothing. The working form is `{"kinds":[7],"#h":["<channel-uuid>"]}`,
and it works regardless of how the reaction was signed: explicit `h`
tags on the event are matched directly, and tagless reactions match via
the stored channel fallback (`crates/buzz-core/src/filter.rs:78-91` —
fallback applies only when the event has no `h` tags; explicit tags are
authoritative).

`NOSTR.md` already documents this exact pitfall for group-metadata
events:

> **Note:** Channel-scoped storage means live global subscriptions
(`{kinds:[39000]}`) won't receive these via fan-out.
(`NOSTR.md:124-126`)

…but has no equivalent note for reactions, which is the case a
bot/integration author is far more likely to hit: any client that wants
to observe approvals/reactions live (workflow reaction-triggers make
this a first-class pattern in Buzz) will naturally try a kinds-only REQ
first and conclude reactions are broken. We lost real debugging time to
exactly this while building a headless integration
(https://github.com/OriginTrail/buzz-dkg-integration); the behavior is
by design, only the docs are missing.

## What this PR changes

Docs only (`NOSTR.md`): a subscribe-to-reactions example in "Sending
Messages", plus one note mirroring the existing 39000 note. No code
changes.

## How to verify

- Behavior: with the relay running, open a live REQ `{"kinds":[7]}` (no
`#h`) and react to a channel message from another client → nothing is
delivered; re-subscribe with `{"kinds":[7],"#h":["<channel-uuid>"]}` →
the reaction arrives.
- Claims against code (verified at `485d03a`): scoping invariant
`crates/buzz-relay/src/subscription.rs:386-393`; channel derivation
`derive_reaction_channel()` in
`crates/buzz-relay/src/handlers/ingest.rs`; `#h` fallback
`crates/buzz-core/src/filter.rs:78-91` and its test
`h_tag_fallback_uses_stored_channel_id`.

Duplicate search: no existing issue/PR found for `reactions
subscription`, `fan-out kinds` (searched 2026-07-29). DCO signed-off.

---------

Signed-off-by: Žiga Drev <ziga.drev@gmail.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: Žiga Drev <ziga.drev@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-01 12:33:43 -04:00
36cf932ff0 docs(chart): fix ArgoCD example for native OCI sources (full artifact repoURL + path) (#3426)
## Problem

`examples/argocd-app.yaml` uses the split form:

```yaml
repoURL: oci://ghcr.io/block/buzz/charts
chart: buzz
targetRevision: 0.1.0
```

On ArgoCD >= 3.0 (native OCI sources), the `chart` field is **ignored**
for `oci://` repoURLs, so ArgoCD tries to pull the `charts` path itself
and fails with `403 … repository:block/buzz/charts:pull denied` — a
misleading error that reads like an auth problem. Additionally, spec
validation rejects the Application without a `path`
(`spec.source.repoURL and either spec.source.path or spec.source.chart
are required`), since `chart` isn't recognized for OCI.

Hit both on ArgoCD 3.4.4 following the example verbatim.

## Fix

Use the full chart artifact path as `repoURL`, add `path: "."`, bump the
pinned example version to the latest published chart (0.1.6), and leave
a comment explaining both traps:

```yaml
repoURL: oci://ghcr.io/block/buzz/charts/buzz
path: .
targetRevision: 0.1.6
```

Verified working in production (ArgoCD 3.4.4, anonymous GHCR pull, chart
0.1.6).

Related open PRs/issues: none found.

---------

Signed-off-by: Kampe <blindside328@gmail.com>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Kampe <blindside328@gmail.com>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
2026-08-01 12:08:45 -04:00
8d5afb6067 docs(readme): clarify which release asset to download per platform (#3481)
## Summary

The "I just want to try the app" section names platforms generically
(macOS `.dmg`, Linux `.AppImage` / `.deb`, Windows `.exe`), but the
release publishes five assets, including two separate macOS builds. A
first-time user on a Mac has no way to tell whether they need `aarch64`
or `x64`, and nothing sets expectations for the SmartScreen warning on
the unsigned Windows build.

This replaces that sentence with a platform-to-filename table, a
one-line note on how to check which Mac you have, and a note that the
Windows build is unsigned and what the warning looks like.

Filenames use `<version>` rather than `0.5.0` so the table doesn't go
stale each release.

### Related issue

None found. Searched open issues and PRs for README/download/install
topics.

### Testing

Docs-only change, no code paths touched. Verified the table and
paragraph breaks render correctly in GitHub's markdown preview.

---------

Signed-off-by: Dan Sheehan <dannysheehan90@gmail.com>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
2026-08-01 12:07:42 -04:00
5765fc74b7 fix(relay): allow open relays to set their NIP-11 workspace icon (kind:9033) (#3998)
## Problem

The desktop deliberately shows the workspace icon editor on open relays
(#2640, gate: `canEditIcon` in
`desktop/src/features/communities/ui/EditCommunityDialog.tsx`) and
defers to the relay-side kind:9033 check — which required an admin/owner
row in `relay_members`. For a community with **no admin/owner row at
all** (the `ensure_configured_community` path, which never writes an
owner), every 9033 was refused and the icon was permanently unsettable.

**Correction from review (thanks @Dawn):** the original version of this
PR claimed nobody holds a role on an open relay. That's false —
`main.rs` bootstraps `RELAY_OWNER_PUBKEY` as owner regardless of
`BUZZ_REQUIRE_RELAY_MEMBERSHIP`, so a production open relay like
bb-block *does* have an owner row, and the old gate was refusing
everyone except that owner. The first revision of this diff would have
silently widened that owner-only control to any NIP-42-authenticated
sender.

## Fix — steward-wins

`may_set_workspace_profile(sender_role, membership_enforced,
community_has_steward)`:

| Relay mode | Community has admin/owner row? | Who may set the icon |
|---|---|---|
| Closed (`require_relay_membership=true`) | any | admin or owner
(unchanged) |
| Open | yes (e.g. bb-block) | admin or owner (unchanged posture) |
| Open | no (genuinely rosterless) | any NIP-42-authenticated sender |

- New DB helper `has_admin_or_owner(community)`
(`crates/buzz-db/src/relay_members.rs`); the call site only queries it
on open relays.
- The rosterless admit logs a `warn!` with the sender pubkey — 9033
writes no audit row and publishes no announcement event (unlike
9030/9031), so this is the only durable attribution.
- Kinds 9030–9032, NIP-42 auth, `AdminUsers` scope, ban gate, and icon
validation are all untouched.
- Doc comment fixed: cited nonexistent `canEditCommunityProfile`; real
symbol is `canEditIcon`.

## Test coverage — closing the mutation gap

Dawn's mutation testing showed the original unit tests pinned only the
helper's truth table: inverting the flag at the call site or deleting
the gate entirely survived the full suite.

- Unit tests now cover the 3-arg truth table (closed
steward-independent, open-with-steward stays steward-only,
rosterless-open admits).
- Two `#[ignore]`d Postgres integration tests drive
`handle_relay_admin_event` with a real `AppState` (open rosterless admit
→ steward appears → roleless refused again; closed relay member
refused). Wired into the Backend Integration CI job as a dedicated
nextest step.
- **Both of Dawn's mutants verified killed** at this head: flag
inversion fails 1 unit test; gate deletion fails both integration tests
(`Ok(())` where `Rejected` expected).

## CI wrinkle found and fixed: pre-existing schema drift

The first Backend Integration run of the new 9033 tests failed with
`column "icon" of relation "communities" does not exist` — migration
`0003_community_icon.sql` added the column, but `schema/schema.sql` (the
desired-state file that CI job applies via pgschema) was never updated.
Pre-existing drift, invisible until a test in that job actually wrote
the column. Fixed in `297148f62` (3-line addition to
`schema/schema.sql`).

## Receipts (at `1b4b52db8` code / `297148f62` head)

- `cargo test -p buzz-relay`: 835 pass, 1 fail —
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`,
pre-existing (fails identically at the old base and on clean main);
`telemetry::trace_context_lookup_does_not_enable_callsites` is a known
order-dependent flake, passes in isolation.
- `cargo test -p buzz-db`: 94 pass.
- Both ignored integration tests pass live against local Postgres.
- `cargo fmt --all -- --check`: clean.
- Live-local pass per TESTING.md at this head (release build, relay on
:3199, real WS + NIP-42 via nak):
- open rosterless: roleless key sets icon → NIP-11 serves it; `warn!`
with sender pubkey in the relay log
- open + owner row inserted: fresh roleless key refused ("must be admin
or owner"); owner sets icon
- closed relay (owner bootstrapped, `BUZZ_RELAY_PRIVATE_KEY` set): plain
member refused, owner sets icon, `javascript:` URL rejected, empty icon
clears (NIP-11 → null)

---------

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-08-01 12:03:31 -04:00
TanskyandGitHub 3d7712cc36 docs: note that addressable channel events scope by d, not h (#4103)
The channel scoping note in `AGENTS.md` reads as universal:

> **Channel scoping**: Channels use `h` tags (NIP-29 group tag), not `e`
tags.
> Filters and queries must scope to `h` tags when operating within a
channel.

It holds for events inside a channel, but not for the addressable events
that
describe one. kind:39000, kind:39001 and kind:39002 carry the channel id
in
their `d` tag, which is what `get_channels` already reads.

Taking the existing wording at face value while working on kind:39002
produces
an empty result rather than an error, since those events do carry `h`
tags in
other flows, so the mistake is quiet and costs a debugging cycle. Came
up while
working on #4023.

Four lines, no behaviour change.

Signed-off-by: Szymon Tanski <szymontanski8@gmail.com>
2026-08-01 08:53:41 -04:00
909a3b2c31 docs: fix stale kind count, quick-start numbering, and empty Further Reading (#2613)
## Problem

Three small documentation defects, each verified against the code at
06e3d82b:

1. **ARCHITECTURE.md (Event Kinds section)** says `buzz-core` defines
"all 81 kinds". The registry has grown: `ALL_KINDS` in
`crates/buzz-core/src/kind.rs` now has **127** entries (all unique
values). The sentence also says every kind is `pub const KIND_*`, but
registry entries such as `RELAY_ADMIN_ADD_MEMBER` do not use that
prefix.

2. **NOSTR.md Quick Start** numbers its steps 1, 2, 3, 5 — there is no
step 4. PR #797 (2a038515) collapsed the old steps 1-4 (dropping the
separate "Start infrastructure" step) into 1-3, but the final "Connect
any NIP-29 + NIP-42 client" comment kept its old number 5.

3. **NOSTR.md "Further Reading"** is an empty heading — the section's
only content (a link to `crates/buzz-proxy/README.md`) was removed in PR
#1321 (14fba21e) along with the proxy crate itself, leaving a dangling
header as the last line of the file.

## Fix

1. Reworded the ARCHITECTURE.md sentence to defer to
`crates/buzz-core/src/kind.rs` as the source of truth, with the current
count (127) as an explicit "at the time of writing" snapshot, so the
sentence stays truthful as kinds are added. Also removed the incorrect
`KIND_*`-naming claim.
2. Renumbered the final quick-start step 5 → 4.
3. Populated Further Reading with three durable links: the upstream
nostr-protocol/nips repo, this repo's `docs/nips/` extension documents,
and `ARCHITECTURE.md`.

Docs-only; no code changes, no build impact.

## Verification (each claim ~30 seconds)

- Kind count: `python3 -c "import re;
s=open('crates/buzz-core/src/kind.rs').read(); m=re.search(r'ALL_KINDS:
&\[u32\] = &\[(.*?)\];', s, re.S); print(len([e for e in
m.group(1).split(',') if e.strip()]))"` → 127. All 127 values are
distinct. Non-`KIND_*` entry example: `RELAY_ADMIN_ADD_MEMBER` (kind.rs,
in `ALL_KINDS`).
- Missing step: `grep -n '^# [0-9]' NOSTR.md` on main shows `# 1.`, `#
2.`, `# 3.`, `# 5.` in the Quick Start block; `git show 2a038515 --
NOSTR.md` shows the renumbering that orphaned step 5.
- Empty section: `tail -1 NOSTR.md` on main is `## Further Reading` with
nothing after it; `git log -S'buzz-proxy/README' --oneline -- NOSTR.md`
shows the content removal in 14fba21e (#1321).

## Links

- `crates/buzz-core/src/kind.rs` — `ALL_KINDS` registry (source of truth
for the count)
- PR #797 / 2a038515 — introduced the step-numbering gap
- PR #1321 / 14fba21e — emptied the Further Reading section

Signed-off-by: Sean Gearin <sgearin@gmail.com>
Co-authored-by: Sean Gearin <sgearin@gmail.com>
2026-08-01 08:50:25 -04:00
8e81afa431 fix(desktop): keep thread-open affordance in archived channels (#4012)
## Problem

Threaded replies "disappeared" from archived Buzz channels: the **"N
replies →"** summary row and the huddle-started **"View thread"** button
vanished, so existing threads were unreachable from the channel
timeline. The thread data was intact — this was a UI gate, not data
loss.

## Root cause

A single `onReply` prop drove two distinct affordances:
- the **compose** affordances (hover "Reply" button, inline reply
target), and
- the **view** affordances ("N replies →" summary row, huddle "View
thread").

`ChannelPane` nulls `onReply` on archived channels to keep them
read-only. That correctly hid composing — but also hid the view
affordances, since they keyed off the same prop.

## Fix

Two independent props, one per concern:

- **`onReply`** drives the compose affordances and is gated on
`archivedAt` — nulled on archived channels, so no new replies can be
started.
- **`onOpenThread`** drives the view affordances and is passed
regardless of archived state, threaded `ChannelPane → MessageTimeline →
TimelineMessageList → MessageRow`.

Opening a thread on an archived channel is read-only: the thread panel's
composer is independently gated via `isComposerDisabled` (includes
`archivedAt !== null`, `ChannelPane.tsx:318`).

### Before
<img width="811" height="794" alt="Screenshot 2026-07-31 at 20 26 00"
src="https://github.com/user-attachments/assets/670d9db4-30da-4c6d-97dc-275b5dbebca8"
/>

### After
<img width="873" height="791" alt="Screenshot 2026-07-31 at 20 28 04"
src="https://github.com/user-attachments/assets/88525231-2539-4eb3-8117-8e58a0cb3855"
/>

## Validation

- `pnpm typecheck` clean
- biome lint clean on touched files
- full `pnpm test` suite green (3885 tests)
- pre-push `branch-skew` / `desktop-check` / `desktop-test` hooks passed

Signed-off-by: Trey Wood <treyw@squareup.com>
Co-authored-by: npub14h0tw3uj7jm77qfxcwn6um2s5h55l0klrt2w9srzp3m3yvjc0mpsjsuk6e <addeb74792f4b7ef0126c3a7ae6d50a5e94fbedf1ad4e2c0620c771232587ec3@buzz.block.builderlab.xyz>
2026-08-01 08:41:42 -04:00
19d57b0d46 docs: add one-click Railway deploy for a hosted relay (#2733)
Adds a **"I want my own hosted relay"** path to *Getting started* with a
one-click Railway deploy button.

Buzz today asks anyone who wants a real relay to take the
build-from-source route. This gives non-developers a hosted option: the
template provisions the relay plus Postgres, Redis, and media storage,
runs migrations, and generates the owner identity on first boot — no
configuration.

The listing is flagged **community-maintained, not an official Block
build**, so there's no implied ownership. Happy to adjust wording,
placement, or drop the button and keep just a link if you'd prefer.

Template deploys green end-to-end; the owner key is surfaced as a
paste-ready `nsec1…` in the deploy logs, and one deployment can host
multiple communities by hostname.

_Note: this supersedes the stale #984 — that template modeled a
since-removed Typesense service and didn't run migrations._


### Checklist

`README.md` only, +8 −0 — no source files touched, so the build/test
items don't apply.

- [x] `just ci` passes (fmt + clippy + unit tests + mobile) — n/a, no
code changed
- [x] Integration tests pass (`just test`) — n/a, no code changed
- [x] New public APIs / tools / endpoints are documented — none added
- [x] No new `unwrap()` in production code paths
- [x] No new `unsafe` blocks

### How to verify

Click the button in the rendered README. The template stands up the
relay
with Postgres, Redis and media storage wired, runs migrations, and
prints the
owner key once in the deploy logs. Walkthrough with screenshots:
https://hmseeb.github.io/buzz-railway

---------

Signed-off-by: Haseeb Azhar <hsbazr@gmail.com>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
2026-07-31 22:36:56 -04:00
3ade48d503 fix(desktop): point Oh My Pi preset at omp.sh (#3516)
## Summary

Points the Oh My Pi preset at the `omp.sh` installation page instead of
the GitHub repository.

The project serves its current installer from `omp.sh/install.sh`.

### Related issue

Extracted from the maintainer request in #3111. I found no matching open
pull request in a final duplicate check.

### Testing

`https://omp.sh/` returned HTTP 200 with the installation page.

`https://omp.sh/install.sh` resolved to the current installer and
returned HTTP 200.

`cargo test --manifest-path desktop/src-tauri/Cargo.toml preset_entry --
--nocapture` passed 5 tests.

`just ci` passed.

This changes metadata only, so screenshots do not apply.

Signed-off-by: Shreyash Vengurlekar <262980978+kiranmagic7@users.noreply.github.com>
Co-authored-by: Shreyash Vengurlekar <262980978+kiranmagic7@users.noreply.github.com>
2026-07-31 22:30:10 -04:00
fa1a5b1a79 fix(mesh): stop restarting a busy or loading shared-compute node (#3909)
## Problem

Sharing compute with a large model (e.g. `gemma-4-26B`) put the desktop
app into a **restart loop**: toggle Share → app appears to "download" /
stall → the whole app restarts → repeat. Small models (E4B) were
unaffected, which made it look model-specific and flaky.

It is not model-specific and not flaky. It is a **false-positive
liveness check**.

## Root cause (proven by black-box measurement)

A `serve` node's OpenAI ingress (`:9337`) serializes **all** HTTP —
including the `/v1/models` liveness probe — behind the current in-flight
inference. It is *also* HTTP-unresponsive during model load and
package-layer download. In every one of those phases the node is alive
and progressing, but it cannot answer an HTTP probe.

Measured on a standalone `gemma-4-26B` node (randomized ~30k-token
prompt, cache-miss):

| during one ~30s inference | result |
|---|---|
| concurrent `GET /v1/models` | **27.0s**, then 200 |
| concurrent small `/chat/completions` | **28.8s**, then 200 |
| `tcp_connect(:9337)` throughout | **~0ms** |

Both HTTP calls simply queued behind the turn; TCP kept accepting
instantly. A probe with any timeout shorter than the turn reads the node
as dead.

Buzz then acted on that false "dead" reading in two places, **both
restart paths added in #2823**:

1. **Ingress watchdog** — after 2 consecutive `/v1/models` timeouts,
evicts the node; for a serve node eviction means
`app.request_restart()`. Two dead probes landing inside a prefill window
→ restart loop.
2. **Start / restore paths** — on a `wait_for_mesh_inference` timeout,
`stop()` the node and (fresh start) `request_restart()` the app "to
guarantee cleanup" — even though the node was still loading weights or
downloading layers. This is the exact line in the incident log: `started
node failed inference readiness … Buzz is restarting`.

## Fix

Treat a **bound TCP port as alive**. Death has exactly one unambiguous
signal: a *closed* port.

- **Watchdog** (`recovery.rs`): only `PortClosed` may evict. A
bound-but-HTTP-unresponsive `Unhealthy` port is never evicted, at any
probe streak or urgency. Closed-port eviction is unchanged.
- **Start / restore** (`commands/mesh_llm.rs`): install the runtime
**before** probing readiness (so it is always tracked by `AppState` and
can never be orphaned — which is what the restart was guarding against),
and on a readiness timeout **leave it warming up** instead of
stopping/restarting. Launch-restoration stays disarmed until real
inference is confirmed, so a genuinely broken start is retried next
launch rather than silently disabling Share Compute.

### What this deliberately does *not* do

Detecting a node that is bound-but-internally-wedged needs a liveness
signal that bypasses the inference lock. There is none today, so this
fix cannot distinguish "wedged" from "busy" and errs toward not
restarting. That gap is a mesh-llm bug, filed upstream:
**Mesh-LLM/mesh-llm#1126** (lock-free `/live`+`/ready` on the ingress).
A follow-up here can consume it once it lands.

## Tests

- Watchdog never evicts a bound/busy port at any probe streak or urgency
(the regression).
- Closed-port eviction still fires (dead listener still reclaimed).
- Black-box: a listener that accepts TCP then stalls HTTP classifies as
`Unhealthy`, not `PortClosed`.
- **Mutation-proven**: reverting the eviction rule to the old
count-based logic fails the busy-node test.

`cargo test` (desktop, `--features mesh-llm`) green, fmt + clippy clean.

## Not covered here

The intermittent nature means I could not force the live loop
deterministically on a warm machine; the proof is the measured
serialization + the mutation-proven unit/black-box tests. Live behaviour
(app no longer restarts while a 26B node loads/serves) still merits a
manual check before merge.

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
2026-07-31 22:19:30 -04:00