| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
| |
group_invite_links holds sha256(ticket) and the invitee's address blind
index; redeeming grants membership to that account only. Owner-only
create/list/cancel (a node token may create, never mail), 20 outstanding
per group, optional mail written by the hub itself and capped at 10 per
sender per day (mail.invite_link_daily_cap). MESHBAY_DESIGN.md §3.4 now
carries the whole link design.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The plan was a working document; what it decided lives in
MESHBAY_DESIGN.md and MESHBAY_NODE_PROTOCOL.md. Code and tests now
cite those instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
New invite kind "link": member of one group, once, never operator, not
spendable by an active member, capped at 20 per group, cancellable by
handle. Signed ops invite_link_create / invite_cancel, loopback routes,
and the known-device join path now accepts a link code. Adds the plan,
docs/invite-links.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
node_pk in handshake_challenge is now signed over the channel binding
and both nonces, so a client can check the node key before a join
rather than only at the ack. Both transports; the browser and the QUIC
client refuse a wrong signature and treat an absent one as an older node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
A "Send the invitation by e-mail" box under the Invite member field,
checked by default and stored as the invite_email preference. Unchecked,
invite-notify is never called and the hub never sees the code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
The toolbar's actions on the row under the pointer, sharing one action list
with the toolbar — which keeps showing what does not apply, disabled, while
the menu leaves it out. A count only where more than one item is concerned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
A native confirm() or alert() leaves the desktop client unable to type
until the window is refocused. ask.js draws both in the page; the SPA
test now bans all three browser dialogs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
`ops.revoke_member` and `ops.unpin_member` log nothing to audit.db while
the MNP handlers doing the same work audit it. Parked in §15.3: a
removal from the node page or the CLI leaves a refusal with nothing to
explain it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
The node half called `unpin` after `revoke`, and an unpin takes no group:
it deletes the identity and every member row the account holds on this
node, and drops the stored keypair bundle with them. Taking somebody out
of one group took them out of all of them, silently. `revoke` is
group-scoped and is what withdraws the key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
A reconnection restarted the stream at the playhead, and that empties the
source buffer — spending the whole read-ahead at the one moment it was the
thing carrying the film. It carries on from the end of the buffer instead,
except where the buffer is short, the stream already ended, or a cast is
active: the receiver is fed from the wire and never had that buffer, so
resuming there would skip it forward by the lot.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
A browser limits bytes, so a bound in seconds had to be sized for the
highest-bitrate file and every ordinary one then held a fraction of what
the same buffer would have taken. Floored at the old 90 s so nothing
pulls less than before, and walked up rather than declared. A refused
append now waits for an eviction instead of retrying on every tick.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
A member row appears only when a code is consumed, so revoking someone
invited to the wrong group was refused for having no row — and the node's
refusal aborted the browser's removal before its hub half, leaving them a
member everywhere with a live code. Revoking now cancels unredeemed codes
for that group, and a node refusal no longer cancels the hub removal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
| |
One PasswordInput for all three fields, with an eye toggle inside the box.
Out of the tab order, type="button" so a click cannot submit the form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
| |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
QUICKSTART Step 4 named the wrong page. Recorded in §15.3 alongside it: the
key is read from `meshbay-node status` or the desktop client and from nowhere
else, which is a decision rather than a gap — anyone linking a node is already
at a shell prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
It was only the Node ID row's fallback, so it vanished the moment the node
announced itself — and was labelled Node ID when it did show. Own row, copy
button, and the profile's instructions no longer name the node dashboard
removed on 2026-09-01.
`.node-key` wraps: 44 base64 characters with no break opportunity pushed a
412px document to 417px, which unpins every sticky element on the page. The
width probe now measures the Overview table with a real key in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`test_sticky_header.py[firefox]` failed with twelve setup errors whenever
a Firefox was open, and the note in CLAUDE.md said to close it and trust
the Chrome half meanwhile. The cause was not snap's single instance: the
harness pointed HOME at a throwaway directory, snapd sets its own HOME
inside the sandbox, and that directory came back empty on every run — so
Firefox opened the real profile, which the open browser locks.
`--profile` needs a path the sandbox can see: the snap has a private
/tmp, and its home interface grants no hidden directory. Both refusals
print "Firefox is already running", which it also prints when nothing is.
24 passed in 15s with a Firefox open throughout, against 12 errors in 60s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`.admin-table` column heads are `white-space: nowrap`, which meets a
translated string: "Username" is one word, "Nom d'utilisateur" is three,
uppercased with letter-spacing on top. The heads alone overflowed a 360px
window by 11px, and on Android an overflowing document takes every sticky
element with it.
11px was only what could be measured: the probe served no members, so the
table measured its headers and nothing else. With one realistic username
in it the same page overflowed by 248px, at 420px-wide windows too. The
cells get `word-break: break-word`, as `.file-name` already had.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |\ |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`RevocationSubscriber` kept its revocations in two in-memory sets. That is the
defect **H4** names: revocations that live only in memory mean a node restart
silently un-revokes everyone, and group revocations were dropped entirely. The
fix was the persisted `Denylist`, fed from `hub_client.maintain_ws` through
`daemon.py`'s `on_revocation`, which verifies the token against the hub key and
calls `deny_user` / `deny_group`.
So this is not merely unused code. It is the version the register records as
wrong, left beside the one that replaced it — and its docstring still opens with
"Usage in daemon:" and three lines of instructions for wiring it up, which is the
worst shape dead code takes: it reads as the way to do the thing. **L7** is about
exactly this, and the index cache removed two days ago was the same shape.
Certainty before deleting, since that was the condition: no import of it anywhere,
static or dynamic; no entry point; nothing in packaging, the systemd units, the
man page, QE or the documentation; and no test covers it. Every module in the
three packages still imports, ruff passes, and the suite is 2893 — unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
The pass before this applied ruff's own fixes. These are the ones needing a
decision, and the point of doing them is that `ruff check .` now passes: a
linter reporting 98 known-acceptable findings reports nothing, because the next
real one arrives invisible.
**Lines over 100 (70).** Mostly wrapped where they stood. Two exceptions: the
aligned trailing comments in `protocol.py`'s message table were shortened rather
than wrapped, because wrapping one row of a table breaks the table; and in
`models.py` the column comments moved above their columns for the same reason.
**Imports below the first statement (14).** `csam.py` kept its FastAPI imports
under a section header halfway down the file; two node tests had a constant and
a `pytestmark` wedged between two import blocks. Moved, not suppressed.
**Bindings nothing reads (4).** Three in tests, where the call stays and only the
name goes — `_user(client, "listener")` is there to create the user, not to
return one. The fourth was in `revocation.py` and was not a lint finding at all:
`_connect_and_listen` opened an httpx stream to the WebSocket URL, did `pass`,
and then opened the real connection through the `websockets` library. One
pointless request per connect, left over from before that library was used
directly. Removed, and `httpx` with it.
**`l` as a name (4)**, **semicolons (6)** in the POC spikes, and the rest.
2893 passed, the same count as the two commits before it.
`meshbay_node/revocation.py` is worth a decision separately: 154 lines that
nothing imports, superseded by `hub_client.maintain_ws`'s `on_revocation`. This
commit only stopped it failing the linter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`ruff check .` had gone unrun long enough to report 568 errors, which is the
same as having no linter: the next real finding would have been invisible in the
noise. This is the 521 it fixes by itself, in 173 files, and nothing else — the
98 it cannot fix are the next commit.
What actually changed: import sorting (225), imports nobody used (87, none of
them a re-export — no `__init__.py` is touched, which was the one way this could
have broken an import elsewhere), `datetime.timezone.utc` to `datetime.UTC` (69)
and `asyncio.TimeoutError` to `TimeoutError` (18), both plain aliases on the 3.12
this project requires, `Optional[X]` to `X | None` (24), and f-strings with
nothing to interpolate (19).
Checked rather than assumed: every module in the three packages still imports,
and the suite is 2893 passed — the same count, test for test, as the merge
before it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
media_cache held video_meta, photo_meta and thumbs; the audio tags lived
only in the in-memory IndexEntry. So every start re-read every audio file
the node serves, and until that pass landed it served an index with no
artist on any track — one the Music app cannot group. Over a real
6176-file library the pass costs 27.8s cold and 6.4s from audio_meta.
Only what the bytes decided is stored. The filename and folder fallbacks
still run live, or a renamed file would get the old name's answer; the
sibling-cover scan reads the folder, so it stays live too; and a read
that failed is not cached, or one bad read becomes permanent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
The grid's unit is an album, so a track whose artist tag is empty was
drawn nowhere — while `empty` counted it and stayed false, so no message
appeared either. An untagged library rendered a toolbar over a blank
page, with every track one mode-switch away and nothing saying so.
It gets a card, the same shape the singleton folding already mints. No
cover is looked up for it, or for any album this file invented: the
release name is one the browser wrote, and the request cannot match.
music_untagged_probe.py renders the real grid and reads the page back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
_reconnectLoop re-did the handshake and nothing else, so a page kept
whatever it last saw until someone reloaded it. That is invisible until
the node restarts: it rebuilds its index from index_cache.db, which
holds no enrichment, and Music is the one app whose enrichment is
persisted nowhere — for the length of the re-read pass it serves tracks
with no artist, and the album grid drew nothing.
onReconnected was one slot the video player took on open and cleared on
close; it is a listener set now, and carries the fresh ack.
docs/MESHBAY_DESIGN.md §15.3 records the two defects found alongside and
not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |\
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
One conflict, in §15.3's open list, and it was two changes agreeing rather than
disagreeing: this side removed the rows for the third-party search bound and the
node-announcement bound because both are now built (AV27, AV28), while the other
side kept them and added a new one. Resolved by keeping what is genuinely still
open — per-device revocation having no CLI — and leaving the two closed.
`webrtc_server.py` merged without conflict but the two sides met inside one
function: `_upload_chunk` gained the operator's `max_upload_gb` ceiling from
there and the per-group lock and `off_disk` calls from here. Read back rather
than trusted: the operator's ceiling now sits inside the critical section that
keeps chunk ordering, and the unlink beside it goes to the disk thread with
everything else.
2893 passed. The twelve `test_sticky_header.py[firefox]` setup errors are the
open Firefox on this machine, as before.
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
The daemon built the defaults dict for `Roster.node_settings` by hand and
left out `max_concurrent_downloads` and `max_concurrent_uploads`. Absent
from the dict, both resolved to None, were assigned back onto the config,
and the transport skipped them — so node.toml was parsed, validated, and
then replaced by `transfers.py`'s own 8. Invisible to anyone who left the
value at 8, which is the value the template suggests.
There were three copies of that dict and they all disagreed: node_status'
was missing those two and `max_upload_gb` besides. One builder now,
`config.node_settings_defaults`, and the resolver's key list is a class
attribute the tests hold it to, along with the writer's.
1429 passed against a baseline of 1423; the two new behavioural tests fail
with the builder reverted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Found by writing the user guide against the CLI rather than against this
document. §6.7 listed a `member device` verb nobody ever wrote; §9.9 still
called several folders a Photos-only shape after app_directories gave every
application a set; §6.8 said five settings and the Node page shows eight;
§5.5 counted one transfer pool where there are three.
The per-device revocation gap is real and stays open — §15.3 names it as
the capability it is, not as a documentation error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
QUICKSTART takes one Linux machine from downloaded packages to a working
group with a second member. USERGUIDE covers using a group and running a
node, for a reader who is not a developer. Windows stays in
PACKAGING-GUIDE.md.
Both are written from the code — the CLI, ops.py, the systemd units and
the interface catalogue — rather than from the specification, and they
state what is not built as readily as what is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
Was a 4 GB constant in webrtc_server.py, the same on a small board and on
a machine holding a library. Now max_upload_gb in node.toml, on the Node
page and via `meshbay-node transfers max-size`, read from the transport
context per chunk so a change reaches an upload already running.
MESHBAY_DESIGN.md §6.4; §15.3 records a defect found beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`BROWSER_INCOMPATIBLE_AUDIO_EXTS` named the two formats no browser decodes and
was read by nothing. The player asked for `.wma` and `.mpc` and no others, and
`music-player.js` called itself "kept in sync with the node's" constant — so the
entire restriction lived in the caller, and a member's own message is not the
caller.
What that let through: this converts a whole file and holds a transcode slot
shared with video streaming while it runs. One message naming a two-hour film
spends minutes of the operator's CPU and a slot every other viewer is queued
behind. `AUDIO_TRANSCODE_MAX_BYTES` catches the result, after the work is done;
only the extension catches the work.
The client comment now says what it is — an optimisation that saves asking for a
refusal — rather than implying it is the rule.
Two tests: a film is refused before ffmpeg is reached, and a WMA still passes,
because a gate that refuses everything is not a gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
the loop
Two things about the same three functions, which write to a temp file with ffmpeg
and then read it back.
**The read was on the event loop.** These files are ffmpeg's own, under
`tempfile.mkstemp` on the system disk, so they are not a group root and there is
no spun-down platter to serialise against — which is why they go through
`asyncio.to_thread` and not `roots.off_disk`. But a whole transcode read inline is
still tens of megabytes of blocking read while nothing else in the node is served.
The AST guard now covers the module with no exemption at all, and a second check
refuses a direct call to the reading helper: passed to `to_thread` it appears in
the syntax tree as a name, called inline it appears as a call.
**The audio transcode had no size ceiling**, where the subtitle path beside it
has had one all along. The bound is the media cache's rather than memory's:
`put_thumb` writes one SQLite row, and the store is 512 MB with least-recently-used
eviction, sized for thumbnails, posters and short transcodes. At 192 kbit/s a
three-hour source is ~260 MB — one row that evicts most of the cache to fit and is
evicted again by the next few thumbnails. Not a size this store can hold usefully.
64 MiB, about forty-five minutes: past any track, any single piece, most sets.
It takes away nothing that worked. `AUDIO_TRANSCODE_TIMEOUT_SECS` is 120, so a
source long enough to reach this was already liable to be killed mid-transcode;
what changes is that the refusal now names the limit it met and the size that met
it. Serving audio of that length properly means streaming the conversion instead
of buffering it, which is a different feature — recorded in §9.8 rather than left
as an implied promise.
The stat comes before the read, so an oversized result costs a stat rather than
the read and the memory behind it.
Twelve `test_sticky_header.py[firefox]` setup errors again: Firefox is still open
on this machine, and its `[chrome]` half passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
§16 opened by saying "code comments, tests and older documents cite sections
of the documents this one replaces", and closed by saying "nothing needs
editing to follow a reference". Neither holds since 20a8241: the code carries
no reference to a removed document, and that commit is precisely the editing
the second sentence said was unnecessary. Left as it was, that sentence tells
the next reader a dead reference may stay dead.
The table itself is unchanged and stays whole — every row still resolves
something. What changed is who asks: 629 commits of history that cannot be
rewritten, the superseded documents kept under docs/ (cast-smart-tv.md and
WINDOWS-PORT.md both route readers here by name), and notes held outside this
repository. The intro now says that, and states the rule the code follows
instead — cite this document and a section.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
**A free-text TMDB search spends the operator's credential.** TMDB rates it, and
the automatic matching every member sees runs on the same one, so a member
holding down the search box — or a script doing it — degrades the library for
everyone and empties a quota the operator pays for. The handler had no ceiling of
any kind, where link previews beside it carry two. §6.5's standing rule is a bound
and a named adversary in the same commit; this arrived with neither.
Per member and not per connection, unlike link previews: three tabs is one
person, and a ceiling a tab can multiply is not a ceiling. Kept in the group
context, so a reconnect does not reset it — a client that drops its channel
between searches would otherwise have no ceiling at all. The node-wide window
stays too, because the two answer different questions: one keeps a member from
spending everyone's quota, the other keeps a roomful of them from doing it
together. Ten a minute each, thirty for the node — a search every six seconds,
sustained, is past what anyone types.
The refusal is an error rather than an empty list. An empty list is what "no such
film" looks like, and telling somebody their film is unknown when the node simply
declined to ask is a worse answer than the truth; `video-app.js` already puts
`detail` on screen.
**How many node keys one account may announce.** Each is a row in `nodes` plus a
row in the IP log, and the IP log is kept for a year, so an account in a loop
writes a year of storage on the operator's disk having paid only for signatures.
M8 settled whose key it is and said nothing about how many. Ten: a node is a
machine left running, and an account wanting an eleventh *identity* rather than
an eleventh machine is the case this refuses.
Counted only where a row is added. Applied to every announce it would freeze the
address of every node an account already runs the moment it reached the limit,
and a node that cannot re-announce is unreachable after its ISP renumbers it —
an outage caused by the protection. There is a test for exactly that.
Both tests are two accounts, per §13.5b: a ceiling one person can exhaust for
another is not a ceiling but a queue, and a ceiling shared between accounts would
let one member stop every other from bringing a machine online. Checked by
removing each ceiling: seven tests fail.
`test_season_and_search_requests.py` built its session without a `_user_id`,
which production guarantees — `_dispatch_message` refuses every message until the
handshake settles it. The fixture was narrower than the node, so it could not
exercise a per-member bound at all; it has one now.
Twelve `test_sticky_header.py[firefox]` setup errors in a full run here: Firefox
is open on this machine, the trap CLAUDE.md describes, and its twelve `[chrome]`
tests covering the same geometry pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
`group_indexes` was an IndexedDB store holding a decrypted copy of each group's
index — every file's name, path, size, hash and uploader — written on every index
and on every delta, from three call sites.
It was the cross-group search of Phase 10b: `doSearch` read `getAllCachedIndexes`
and searched those records instead of dialling anything. On 2026-08-28 Search
began dialling the nodes, and that commit removed the reader and left the writers.
Since then the browser has gone on building a cleartext file listing that nothing
consulted, that no sign-out removed — the key database is a different one — and
that grew with every group ever opened. L7, at rest: kept code that nothing calls
does not sit still.
Drawing a group's files while its node is unreachable is the only thing such a
cache buys, and it is not wanted: a listing that cannot be opened is worse than an
honest absence. So there is nothing to read it with, and the writers go.
The store stays in the schema and is emptied instead. Dropping it needs a version
bump, a version bump is an upgrade another tab can block, and playlists share this
database — so the tidier change is the one with a failure mode. `purgeGroupIndexCache`
runs once per browser behind a flag, which clears what is already on people's
machines; a browser that refuses storage simply runs it again, which is harmless
because it is idempotent.
Three guards, each checked by reintroducing the fault: only `openDB` and the purge
may touch the store, the purge may only clear it, and the purge must actually be
called at start-up — a purge nobody calls is the same defect wearing the opposite
hat.
`test_sticky_header.py[firefox]` reports twelve setup errors in a full run here.
A Firefox instance is open on this machine, which is the trap CLAUDE.md describes;
the same twelve appear with these changes stashed, and the `[chrome]` half of the
same file, covering the same geometry, is clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
The per-feature design notes were merged into docs/MESHBAY_DESIGN.md and
deleted from the tree on 2026-09-11, but ~230 comments across the three
packages still named them — usually written `docs/musicbay.md §3.2`, as
though the file were still in docs/. A reader had to know §16 existed to
resolve any of them. They now name the section directly.
Every mapping comes from §16, the concordance, which already records where
each old section landed: musicbay -> §9.8, mediacenter -> §9.7 for the
Videos app and §6.5 where the subject is derived data, photos -> §9.9,
auth-confirm -> §3.6, refactoring-search -> §9.11, invite-pairing-v1 ->
§3.4, per-node-identity-v1 -> §3.2, captcha -> §7.7, chat-sender-keys ->
§4.5, apps/refactor-groups -> §9.1–§9.4, desktop-client-v1 per section.
Bare citations of the same documents (`draft-v6 §2.11`, `§4.8`, `§3.4`)
are retargeted too: those collide with real section numbers in the design
document, so leaving them would have been worse than the named form.
Four cases the concordance does not cover, each decided rather than guessed:
Sub-item references into documents that no longer exist — mediacenter's
`§3.3 row 4`, `§3.4b/c`, `apps.md §3b` — name rows and sub-items §9.7 and
§9.2 do not reproduce. The module-level citation stays; the sub-item
pointer is dropped.
The V-findings keep their labels but lose the dead `§10.1/` prefix.
§13.8 lists V1–V13 as per-application open items, which is not what the
labels mean in these comments, so pointing them at §13.8 would have been
a false citation.
`apps.md §5`'s virtualization requirement has no counterpart anywhere in
the design document. The requirement is stated in the comment itself, so
the citation is dropped rather than aimed at a section that does not say
it.
Comments that attributed a *sentence* to an old note — musicbay's "several
thousand files" example, its "what got measured" note, its measured
~11%/~26% cover-art figures, the "original no root, whole shared tree"
call — state the fact without attribution now. §9.8 does not contain those
sentences and citing it for them would have been wrong.
CLAUDE.md's "a reference to a document that no longer exists" row now says
the concordance is for git history and out-of-tree material; the code cites
sections directly.
Verified: 2851 passed, 4 skipped. The 12 errors in the run are the Firefox
leg of test_sticky_header.py's browser harness, which is broken at the
browser level on this machine — headless Firefox (snap) dies with
`[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer`, renders
nothing, and the probe exits `{"error": "no measurement"}` after its full
90s wait. Chrome runs the same 12 assertions in 3.2s and passes. Nothing
here can affect it: every changed line in style.css is inside a comment.
Also checked: ast.parse on every changed .py, `node --check` on every
changed .js, the /* */ balance in style.css, and that no changed line
exceeds the width its file already used.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Seventeen comments across the Windows packaging targets pointed at
C:\Users\admin\devel\light-client.md and msix-installer.md -- absolute
paths on one developer's machine, unreadable to anyone else who clones
this repository and unverifiable by any test here.
The surrounding prose already carried the substance in every case, so
these are removals, not rewrites, with two exceptions where the pointer
was doing real work:
electron-builder.msix.yml's header told the reader to go read §4 and §8
first. It now states the fact directly (an AppX/MSIX install never
elevates, by design, so the package carries none of installer.nsh's
elevation logic) and says the open items are called out at each
declaration below -- which they already were, at `capabilities` and
`customExtensionsPath`.
The two "msix-installer.md §8" citations become "an open item" / "the
other open item", beside the description of the item that was already
there.
Paragraphs the removals left ragged are re-wrapped.
Verified: node suite 1403 pass / 4 skip (71 of them test_packaging_win.py),
both electron-builder configs still parse as YAML, node --check on main.js.
The .ps1 edits are inside <# #> comment headers plus one deleted Write-Host
in a block that keeps two others; no pwsh on this machine to parse them.
Not touched, and much larger: ~230 comments elsewhere in the codebase cite
per-feature design notes (musicbay.md, mediacenter.md, auth-confirm.md and
twenty more) that were merged into docs/MESHBAY_DESIGN.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A node is a machine in somebody's house, so with a handful of groups one is
always off. Search treated that as the exception and charged the reader for it.
Measured on twelve groups against a virtual clock, a live group answering in
200 ms and a dead one taking the full deadline: one node down in the first batch
put a blank page and a progress bar in front of the reader for **ten seconds**,
while two groups of that same batch had answered in two hundred milliseconds and
nine others had not been dialled at all. Four down, spread out — the shape a real
reader has — was ten seconds to the first result and **forty** to the last.
Two causes, neither of them the connection deadline. Results were drawn once a
batch was complete rather than as each arrived, so an index already in hand waited
on a node that was not answering. And the batches were sequential, so a dead group
did not merely cost its own deadline, it postponed every group behind it.
So the three is a ceiling on concurrency and never a batch — `inFlight` starts the
next group the moment one ends — and each index is drawn when it lands.
That alone is not enough, and the tests say where it stops: a ceiling still lets
silent nodes hold every place at once, and with four of twelve down the last three
live groups still waited out a deadline. So the browser remembers which groups
were silent and dials them last, which puts all eight on screen in 600 ms. The
list is advisory and rewritten from what each sweep saw: a private window, storage
that refuses, a first visit or a node that has come back all fall through to the
hub's own order, cost one sweep, and correct themselves.
The "n groups unreachable" line also waited for the sweep to finish, which is the
one moment it is no longer needed. It now appears as they are found.
First result, before and after, twelve groups: one down 10 s → 200 ms; four down
10 s → 200 ms with every reachable group on screen by 600 ms; three down and
listed first 10 s → 200 ms from the second visit on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Opening a group from the sidebar has no deadline of its own and gets the
transport's: 30 s for the DataChannel, 30 s for each request after it. Search
wrapped the same `connect()` in a flat 10 s, and that 10 s had to cover the hub
round trip, ICE gathering (capped at 4 s in transport.js), DTLS, the channel
opening and the handshake's own round trips. On a phone on 4G the budget was met
by luck rather than margin, and the same group then failed in Search while it
opened from the sidebar, against the same node. The budgets were inverted: the
phase full of round trips had a third of what one request on an open channel got.
Raising the number would have been the wrong repair. `fetchAllIndexes` fans out
three at a time and waits for the slowest of each batch, so a page of unreachable
groups costs batches x the deadline in spinner: a bigger number taxes every dead
group for the sake of the live ones.
So the deadline measures stalling. A node that is not there reports nothing and
still fails in `SEARCH_STALL_MS`, unchanged at 10 s, which is what keeps the
fan-out where it was. A node that answers ICE, then opens a channel, buys another
window at each step, up to `SEARCH_MAX_MS` — a deadline that only ever resets has
none, and a node that answers and then goes quiet would otherwise never be given
up on.
The transport reports those steps through `onConnectProgress`, set by the one
caller that imposes a deadline of its own. `connected`/`completed` is the signal
and not `checking`, because the first means a candidate pair answered and the
second means this side is still trying addresses that may all be dead. A caller's
callback cannot break the connection it is reporting on.
The tests run the shipped `connectToGroup`, lifted out as text, against a fake
clock — a real one would make each scenario a minute and blur the only thing
worth asserting, which is when the deadline fires. Dead node: 10 s. Slow but
moving: connects at 20 s where it used to fail at 10. Answers then stops: 18 s.
Progress that never finishes: the 30 s ceiling. Two dead nodes: two windows, both
transports closed. Checked against the flat deadline, which fails three of them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Both described panels the desktop client would grow of its own. §8.3 settled
that differently: one UI source means anything written for the client is
something the browser then lacks, so both were built in `static/` instead and
the client ships them.
D5 is `node-page.js` over the operator ops — status, groups, roots with eject,
plug and add, roster, peers, pairing, audit export. D6 is `create-group-page.js`,
which detects the node, provisions it, takes the group's name and roots,
initialises the key and pairs. The service unit is the one genuinely native
part, and the desktop main process already runs it: `systemctl --user` on Linux,
Task Scheduler on Windows.
Left standing in §15.2 they read as work outstanding, which is how a plan
outlives the decision that replaced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The last two handlers on the loop. Both were synchronous, so a member creating a
folder on a root that had spun down held the node for the spin-up, exactly as a
chunk read did.
Where a check and an act belong together they are now one call rather than two
awaits, and the single disk thread is what makes that atomic: `_mkdir_if_absent`
so two members creating the same name cannot both find nothing there and have
the second `mkdir` raise where a refusal was meant, and `_rmdir_if_empty` for
the reason the caller already re-tested emptiness — the first test happened
before a round trip to the operator's browser, and a file can land in between.
Two awaits would reopen that window one size smaller.
The guard is now the whole class rather than the calls that were fixed. It walks
the module's syntax tree and fails on any filesystem call outside the handful of
functions written to be run through `off_disk` — a new handler that stats a root
inline would pass every measured test, because those exercise the handlers that
exist today. Checked by putting a call back: it names the function and the line.
It leaves ffmpeg's own scratch files out, listed rather than silently allowed:
they are under `tempfile.mkstemp` on the system disk, not on a group root, so
they are not what spins down — but they do read a whole transcode into memory
from the loop, and the day that matters it is a different measurement from this
one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The rest of AV9's disk half. Serving a file left the loop in the commit before
this one; two paths were still on it.
**The availability poll.** `RootSet.refresh_availability` stats every root, and
eleven call sites reached it from `async def` — the reconcile loop among them, on
a timer. On a sleeping disk that is a stall once per tick, and the stat is also
what keeps the disk awake, so a node paid spin-up for a library nobody was
reading. All eleven now go through `off_disk`, `Root.is_live` included.
**The upload write.** `open`/`write`, and the resolve, the stat, the free-name
search, the rename and the unlink around it. This one could not simply be
awaited: the handler was synchronous, so nothing could come between the
`chunk_index != state.next_index` check and the `advance` that answers it, and
that is the whole of the chunk-ordering rule. Awaiting the write opens the gap —
chunk 1 arriving while chunk 0 is in the disk thread reads a position that has
not moved and is refused as out of order, so an upload would fail on a slow disk
and nowhere else. Verified, not assumed: without the lock the new ordering test
refuses three chunks of four.
So the check, the write and the advance are one critical section again, under a
lock held **per group**. Not per session: `partial_uploads` lives in the group
context so a reconnecting client finds its upload where it left it, which means
two sessions of one member share the position of one `.part` file. Arrival order
is preserved by construction — the dispatcher creates one task per message as it
arrives, tasks start in creation order, and the lock is the first thing each one
waits on, so its waiters queue in arrival order too.
`_do_file_upload` is a coroutine now, which is why forty-two test call sites gain
an `await`. Their outcomes are unchanged, file by file, against the run before
the change.
`test_ops.py` asked which public coroutines `ops` exposes and got `off_disk`,
imported rather than defined there. It now asks for the ones written in the
module, which is what its own docstring means; all forty-three operations are
still checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A root that has spun down, or that lives on a network mount, answers its first
syscall in seconds rather than microseconds. Made from the event loop, that
stalls the whole node: no other group is served, no stream is fed, no chat
message is delivered and the hub socket is not read, for as long as the platter
takes to come back. It was found from the other end — a client's connection
attempt timing out on a node with one member, while the disk woke up — and it is
AV9's lesson with the disk in the place of the mail server.
Every filesystem call on a group's content now goes through `off_disk`, onto a
thread that belongs to that group's root set. The stat goes with the read: a stat
is what *wakes* a sleeping disk, so offloading only the read would move the stall
rather than remove it, and the read would then find the disk already awake.
`_locate` is the one place allowed to call `entry_abs_path`, which is
`Path.resolve()` and therefore syscalls too.
Five handlers touched: a chunk request, an audio transcode, a subtitle request, a
video stream and a delete. The delete became a coroutine, which its one caller
already was.
One worker per root set, not a pool and not one for the node. One worker for the
same reason the indexer's executor has one — two interleaved reads of a spinning
drive seek-thrash rather than go faster — and it keeps two threads from being
inside the same file at once, which is what makes the `seek`/`read` pair safe
without a lock. Per root set, because a node serves several groups and their
roots are not all on the same volume: a single worker would put one group's
sleeping USB drive in front of another's SSD, which is this symptom one level
down. The thread is created on the first read, so a group nobody downloads from
never starts one, and the daemon stops them all on the way out.
The tests measure rather than read: a ticker counts its own wake-ups beside a
request made slow on purpose, and a handler that blocks the loop takes every one
of them with it. Put either call back inline and both tests report zero wake-ups,
which was checked before they were trusted.
QUIC still reads on its loop. Its handler is synchronous by construction, no
client speaks it, and its distance from parity is already recorded in the design
document (§15.3, L3); moving it is part of bringing it to parity, not of this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The two documents disagreed with the code they describe. §15.2 still listed
playlists and Chromecast subtitles as not built, both shipped and tested; the
protocol document's header said wire version 3.2 beside its own §13 saying 3.3,
which is §12's "a number restated away from its definition is a number that will
drift" happening to the document that states the rule.
The packaged install is no longer an open question: the artefacts have been taken
to a running hub and node on Ubuntu 26.04 (.deb), Fedora 44 (.rpm) and Windows 11
(.exe). What is left of D11 is signing the binary, which is a different question
from whether it installs.
The migration row overstated its own risk. Nothing replays the revisions behind
the first packaged release — they were development no installation ever ran — so
the exposure is the next migration, not the chain.
The disk-I/O row gains the consequence that was measured rather than reasoned: a
root waking from standby stalls the loop long enough for a client's WebRTC
connection to time out, on one node with one member. `refresh_availability` is
named beside the two chunk paths, because a stat on a sleeping disk is what wakes
it while reading nothing.
A blank line had also split §15.3's table in two.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A 1080p film is decoded by whoever watches it and re-encoded by the node
when no browser can decode the source. Both were on the CPU, and on an
Atom or Celeron mini-PC neither reaches real time — which is what
`transcode_incompatible_video` exists to refuse. This adds the mechanism
that makes refusing it unnecessary.
Node — `hwaccel.py`: VA-API on Linux, Quick Sync or NVENC on Windows,
established by encoding 1080p and reading the file back with ffprobe.
Nothing is accepted that does not produce the exact profile and level
`stream_init` announces, since the client checks that string before it
trusts a byte: an encoder that wrote another level would make the node's
own codec string a lie, and ffmpeg takes `-level 4.1` and `-level 41`
from h264_qsv without saying which it understood. Three modes per
stream — hardware decode and encode, hardware encode alone, libx264 —
demoted per source codec, because a GPU that decodes HEVC may have no
decoder for MPEG-4 Part 2 and only asking it finds out. A mode that
fails is detected on an empty stdout before `stream_init` goes out, so
the viewer sees one working stream and never an error.
Client — Chromium ships VA-API off on Linux. It is enabled where a
render node and a driver are present, then verified through
`navigator.mediaCapabilities`: a no moves to the next GL backend on the
next launch and an exhausted list drops the switches, so a renamed
feature cannot pass for a feature that is on and `--ignore-gpu-blocklist`
cannot survive on a machine it did not help. Feature lists now merge
rather than overwrite — `appendSwitch` replaces the value, and a second
caller would have silently cancelled the mDNS switch aiortc depends on.
Packaging — the drivers are weak dependencies on all four formats, so a
machine without a GPU installs exactly as before. `dpkg -i` and
`rpm -ivh` ignore weak deps; `packaging/README.md` now says so. Windows
needs no driver: the bundled ffmpeg already carries h264_qsv and
h264_nvenc, and a re-pin that dropped them would cost every low-power
Windows node its hardware encoding silently.
AMD on Windows (AMF) and macOS (VideoToolbox) are named gaps, not
oversights: neither could be tried anywhere in this project, and both
re-encode in software as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Most of what the relay says repeats without bound — a line every fiftieth
fragment for the length of a film, one per dropped fragment whenever a client
falls behind — and it lands in the terminal the app was started from. Those go
through `debuglog` now, restored with:
NODE_DEBUG=cast-relay npm start
The loss of fMP4 framing stays on console.warn. It recovers by rescanning, so
nothing throws and no other part of the system hears about it; that line is the
only trace that the picture on the television is missing a piece. Every other
failure here throws, and the renderer already reports the rejection.
The logging guard now covers both cast modules, each with the one line it is
meant to keep audible.
Also corrects the header comment, which still claimed CORS was granted on the
subtitle path only. It has been on both since the stream needed it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The receiver reports its state on a timer, so `player status: PLAYING` repeats
for as long as a film runs and buries everything else in the terminal the app
was started from. The progress lines now go through Node's own `debuglog`,
which costs nothing when disabled — the message is never formatted — and is
restored with:
NODE_DEBUG=cast-chromecast npm start
The socket error stays on console.error. It fires when the connection to the
receiver dies and is the only trace of why a cast stopped; routing it through
the same switch would make the one failure worth seeing the one that
disappears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The relay forwards the node's fragments untouched, and those begin at zero
at the seek point. The player never notices because its SourceBuffer is given
`timestampOffset = start`; a receiver has no equivalent, so the cues are
shifted by `-start` before they leave, recomputed at every restart of the
relay. Sent as they are, a subtitle would be out by the whole seek.
The document is served from the relay's own port at /subs.vtt, behind the same
token as the stream and with CORS: a receiver fetches a side-loaded track with
XHR from its own origin, and without the headers it fails as a network error
with nothing on screen to say so. The URL carries a version because a track is
cached by address — changing the cues behind a fixed URL leaves the previous
language showing.
Cues that end before the stream begins are dropped rather than clamped, so a
line from before the seek cannot appear over the first frames after it.
The relay is plain Node, so the tests start it and fetch from it rather than
reading its source.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Verifying the feature across a library turned up two faults, one of which
broke it outright.
**The extraction budget was a constant and the cost is not.** Extracting
demuxes the whole container, so the file sets the price: measured at 9.8 s per
GB on a library held on an external disk — 36 s for a 3.9 GB title, 71 s for a
7.3 GB one. Against a flat 60 s that worked on most of a library and failed on
the big films, which to whoever is watching one is indistinguishable from a
broken feature. `Avatar.The.Way.of.Water` timed out every time. The budget is
now three times the measured rate per gigabyte, floored at the old 60 s and
capped so no container can pin a transcode slot indefinitely. The client's
own timeout follows it: the node always answers, so that one is a backstop
against a silent peer rather than a deadline for the work.
**Two clicks ran two extractions.** The cache is consulted on the way in, so a
second request arriving while the first was still running missed it and ran
its own — seen in the log as two identical extractions of one 4.3 GB file
overlapping, each holding a transcode slot and reading the file end to end.
Latecomers now wait on the answer the first is producing.
The in-flight entry is registered *before* the first await, not after. The
first version registered it after the probe, two concurrent requests both got
past the check while neither had registered, and the test for it failed —
which is the only reason this note can be written from the right side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The previous fix read ffprobe's key frames and took the last one at or before
the request. It was wrong twice, and a viewer felt the difference: subtitles
went from 5 s early to 2–3 s late.
Matroska's Cues index only some keyframes, so an index seek backs off to an
indexed one that the frame list does not single out. And the landing point
moves with **which streams are mapped**, because the container is positioned
where every mapped stream has data — on the reported title, a seek to 4913.7 s
landed at 4909.863 with video alone and at 4907.236 with the second audio
track mapped beside it. The frame scan gave the first number; the stream
delivered the second; the gap was 2.65 s, and the measured audio displacement
in the served stream was 2.65 s.
So the node asks ffmpeg instead: the same seek, the same mapping, one copied
frame under `-copyts`, and the answer read back off the result. 0.06–0.07 s,
cheaper than the scan it replaces. The `-ss` argument stays at the request, so
the bytes served are exactly the ones served before — only the number naming
them changes. The probe runs after the audio track is resolved, because it
cannot be right before that is known.
An answer after the request, or further before it than any real keyframe gap,
is discarded in favour of the old label: a number wrong by seconds beats a
fabricated one.
Found by decoding the served stream and locating its first frame in the
source, which put it at 4907.213 s against an announced 4909.863 s. The test
does the same thing rather than comparing the announced number against a
second reading of the same probe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
|