Skip to content

[beta] Decentralized sharded WebGPU compute pool - #4

Open
ThyFriendlyFox wants to merge 3 commits into
mainfrom
cursor/webgpu-compute-pool-beta-b76d
Open

[beta] Decentralized sharded WebGPU compute pool#4
ThyFriendlyFox wants to merge 3 commits into
mainfrom
cursor/webgpu-compute-pool-beta-b76d

Conversation

@ThyFriendlyFox

Copy link
Copy Markdown
Collaborator

Summary

A beta scaffold for a decentralized, sharded WebGPU compute pool: many browser GPUs cooperating to run inference-style workloads, contributed voluntarily to form a shared pool. Off by default behind a feature flag and fully isolated from the existing app.

How it works

Large-model inference is dominated by big matrix multiplies (the core transformer-layer op). The pool splits each matmul into row-block shards and spreads them across every participating GPU, then reassembles the result. Each node is both a worker (computes shards for others) and a requester (dispatches its own jobs).

coordinator (signaling/membership only — never sees shard data)
        ▲                          ▲
   join/SDP/ICE                join/SDP/ICE
   Node A  ◀──── shards over WebRTC datachannel ────▶  Node B
   ComputePool: webgpu · ShardScheduler · PeerLink

What's included (src/compute/)

File Role
webgpu.ts Real WGSL matmul kernel, GPU capability probe, benchmark
ShardScheduler.ts Pure split / build / merge + weighted work-stealing
signaling.ts WebSocket client to the coordinator
peer.ts PeerLink — a peer as a remote Worker over RTCDataChannel
ComputePool.ts Orchestrates GPU + peers; runSharded() / runDemo()
ComputePoolView.ts Beta control panel (status, demo run, per-shard log)
types.ts Task/result/signaling/peer protocol types

Plus compute-coordinator/ — a tiny reference signaling/membership broker (relays WebRTC handshakes only; shard payloads go peer-to-peer).

Enabling

VITE_ENABLE_COMPUTE_POOL=true
VITE_COMPUTE_COORDINATOR_URL=ws://localhost:8787   # optional; omit for solo
VITE_COMPUTE_POOL_ID=mouse-public-beta

A ⚡ launcher then opens the panel. With no coordinator the pool runs as a single node ("solo") and still demonstrates the full shard → compute → merge pipeline on one GPU.

Testing

  • npm run build (tsc + vite) passes; the compute module is isolated and the app is unchanged when the flag is off.
  • Verified the reference coordinator end-to-end over WebSockets: join, peer list, peer_joined, signal relay, and peer_left all pass.
  • The WebGPU kernel and peer datachannel paths require a real browser GPU / ≥2 nodes to exercise live; the solo shard pipeline is the tested-by-construction path.

Beta limitations (see src/compute/README.md)

  • Job primitive is a dense f32 matmul; mapping real model weights/layers on top is future work.
  • Non-tiled kernel (correct, not yet throughput-tuned).
  • No peer result verification/reputation, weight encryption, or anti-abuse — all required before any real public "free inference" deployment.

Note: native iOS sim build (npm run cap:ios:sim) not run here (no Xcode on this Linux VM). WebGPU/WebRTC availability also varies by platform/WebView.

Open in Web Open in Cursor 

cursoragent and others added 3 commits June 23, 2026 21:43
…, pool + beta panel

Co-authored-by: ThyFriendlyFox <thyfriendlyfox@gmail.com>
Co-authored-by: ThyFriendlyFox <thyfriendlyfox@gmail.com>
Co-authored-by: ThyFriendlyFox <thyfriendlyfox@gmail.com>
@ThyFriendlyFox
ThyFriendlyFox marked this pull request as ready for review June 23, 2026 22:06
ThyFriendlyFox added a commit that referenced this pull request Jul 31, 2026
AGENTS.md requires mirroring iOS feature work into kotlin/ or recording why
not. Phases T (terminal screen), F (package manager) and G (the Node layer)
are iOS-only, and until now that was a silent gap in the contract.

Measured rather than guessed, so the record is decision-shaped:

- T and F are portable work, not blocked work: TerminalScreen (27 KB) and
  PackageManager (32 KB) are pure logic over Foundation, HTTP and zlib, all
  of which Android has, and the pyte/pnpm/semver corpora transfer as the
  Android harness.
- G splits 72 % engine-agnostic JS bootstrap (165 KB, ports verbatim) to
  28 % host bridge (65 KB of Swift). Android has no public JavaScriptCore,
  but @JavascriptInterface gives JS synchronous calls into Kotlin — the
  direction require()/readFileSync/execSync need — so parity is feasible
  without a third-party engine and without breaking invariant #4. The
  reverse direction is async, which only reshapes the event loop.

Android build re-verified green (gradlew assembleDebug).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox added a commit that referenced this pull request Aug 5, 2026
Phase T's pure-logic layer in Kotlin, from swift/Mouse/TerminalScreen.swift,
TerminalWidth.swift and the contract half of TerminalPrograms.swift. Faithful
re-implementation, not a bridge (invariant #5): the grid, the alt-screen
save/RESTORE pair, scroll regions, DECCKM, bracketed paste, the 103-range
UAX#11 width table, the byte-at-a-time ANSI parser with its query replies,
TerminalKey's xterm encodings, and the TerminalProgram/TerminalProgramIO
contract.

It is its own Gradle module — pure Kotlin/JVM, no Compose, no android.*, no
dependency but kotlin-stdlib — for the reason phase T learned on iOS: logic
that shares a file with UI is logic no harness can reach. :app depends on it
and dexes it (verified: com/reagentsystems/mouse/terminal/TerminalScreen is
in classes2.dex).

The gate is :screencheck, a main() with no JUnit (invariant #4), shaped like
the Swift harnesses in verify/. Its corpus is the iOS corpus ported assertion
for assertion — verify/main.swift, altscreen, widechars, widetui and the
screen-level half of tty — reading the SAME checked-in fixtures rather than
copies, because two platforms gated by different corpora is a parity claim
nobody can falsify. That includes the strongest one: the captured claude-code
frame rendered against pyte's own output.

  ./gradlew :screencheck:run
  SCREEN CORPUS: 182 checks — grid, parser, widths, keys, program contract,
  pyte frame — MATCH
  (width table: all 199 agree with Python's unicodedata)

Proven able to fail, twice, before it was believed: making TerminalTty.onlcr
a pass-through reddened the pyte cross-check with the exact diagonal shear
the iOS comment describes ("2 of 182 ... MISMATCH", exit 1), and a bogus
width for U+2014 reddened the table ("unicodedata=1 ours=9").

ANDROID_HOME=~/Library/Android/sdk ./gradlew clean :screencheck:run
assembleDebug — BUILD SUCCESSFUL.

Deliberately deferred, not forgotten: NodeProgram (the Node layer, milestone
3) and the less/top programs. Only TerminalTty.onlcr is lifted out of
NodeProgram, because it is tty truth the screen's own gate needs — the iOS
comment says as much where it sits.

Adaptations where the languages differ, semantics unchanged: a Swift
Character (grapheme cluster) is a String, since a Kotlin Char cannot hold an
emoji; the parser walks code points for the same reason; value-type array
copies are explicit deep copies; CellStyle/TerminalCell are immutable data
classes; Sendable and @mainactor have no equivalent and the main-thread
discipline is documented instead. One assertion had to be spelled
differently: Swift compares Strings by canonical equivalence, so its
"combined glyph" expectation is "é"; Kotlin compares code units, so it names
what the cell holds, "é". Same grid, stricter comparison.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox added a commit that referenced this pull request Aug 5, 2026
…stry

`PackageManager.swift` ported to `kotlin/packages/`, a pure Kotlin/JVM module
with no Android dependency — semver (ranges, caret/tilde, prerelease
ordering), the npm registry client, breadth-first resolution with classic
hoisting, integrity-checked installs, the node_modules manifest, `npm:`
aliases, the wasm substitutions and the napi-rs wasm32-wasi binding, plus
`TarGz` (tar, gunzip, raw inflate).

The module is its own for the same reason `:terminal` is: the whole npm
install path has to be gatable without an emulator. Two things follow from
that:

- `TarGz` moved out of `Workspace.kt` into `:packages`, exactly as iOS moved
  it out of `Workspace.swift` — the clone and the installer share one reader,
  and the shared one must be reachable from a harness. It gains what the
  Swift one has and the app copy lacked: GNU base-256 sizes, symlinks, a
  path-traversal guard, and pax `x` told apart from global `g`.
- The module brings its own JSON. `org.json` is in the Android framework but
  not the JDK, so a pure-JVM module using it would cost a third-party
  artifact (invariant #4) and stop building off-device.

The gate is `./gradlew :pkgcheck:run` — a `main()` printing one verdict line,
no JUnit, the same shape as `:screencheck` and the Swift harnesses. The
corpus is the iOS one (`verify/pkg`, `verify/npmalias`, the resolution half
of `verify/napiwasi`): the semver corpus, resolveTree against a real `pnpm
install --lockfile-only`, and real installs of chalk/glob/mkdirp proven by
real `node` requiring out of the tree. 94 checks, MATCH.

Evidence:
- `:pkgcheck:run` — 94 checks MATCH (5 pnpm resolutions agree package-for-
  package; real node loads chalk and glob from our layout)
- `:screencheck:run` — 182 checks MATCH, unregressed
- `ANDROID_HOME=~/Library/Android/sdk ./gradlew assembleDebug` — clean
- the gate was proven to fail twice before being believed: `^`'s upper bound
  bumped by one reddened 9 checks (semver, three pnpm resolutions, both
  real-node layout proofs, both alias legs), and stubbing the integrity
  comparison to `if (false)` reddened the corruption check on its own. Both
  reverted.

One finding recorded in STATUS.md, not fixed here (iOS is frozen for this
loop): `verify/napiwasi` has gone stale for a reason that is not ours —
neither rolldown nor oxc-parser publishes a `…-wasm32-wasi` optional
dependency any more. The Kotlin gate therefore asserts the RULE on a
synthetic napi-rs-shaped package and keeps one live-registry leg on a package
that still ships one.

Deferred to milestone 3 with the Node layer: `npm install`/`npx`/`npm run` in
Kotlin msh. They exist to RUN what they install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox added a commit that referenced this pull request Aug 5, 2026
`NodeSockets.swift` becomes `kotlin/node/.../NodeSockets.kt`: one selector
thread owning every channel, not a thread per socket, because "a dev server
with 50 keep-alive connections must not cost 50 threads" is sharper on
Android, not weaker. The load-bearing rules came across with the reasoning
rather than the code — EOF is not a close, a half-open socket stays writable,
an accepted socket never exists without a handler, nothing may block the
socket queue.

`NodeDns.kt` writes and parses DNS on the wire, including the name
decompression `res_9_dn_expand` does for iOS: Android ships no JNDI, so a
resolver built on `javax.naming` would gate green off-device and be absent on
a phone. The nameservers come from the HOST (`ConnectivityManager`), because
Android has no /etc/resolv.conf and guessing a public resolver would be a lie
about the user's network.

`NodeHttp.kt` is the TLS-capable transport behind `fetch` and `https.request`
— `HttpURLConnection` where iOS has URLSession, same bargain, and delivered
INCREMENTALLY so the head arrives before the body.

Moved out of `HostBridge.DEFERRED`: netConnect/Listen/Write/End/Destroy/
Pause/Resume/Ref/NoDelay/KeepAlive/Resolve, dnsResolve/Reverse/Service/Done,
httpRequest/httpStream, dgramBind/Send/Option/Membership. Still deferred, each
with a reason that is true NOW rather than inherited: unix-domain sockets
(AF_UNIX is `UnixDomainSocketAddress`, API 34, against minSdk 26), the cluster
descriptor handoff (java.nio will not adopt an fd it did not open), and the
WebSocket global (no WebSocket client in the JDK or the framework, and
invariant #4 forbids adding one — the `ws` PACKAGE rides these sockets and
works). `fsWatch`'s reason was edited too: its "the socket layer does not
exist yet" clause stopped being true today.

The bridge partition did its job on the way in. `NodeSmoke` asserted that
`netConnect` REFUSES; the moment sockets were real that became an assertion
that a regression had happened, and the gate said so by name.

Evidence:
  :screencheck  SCREEN CORPUS: 202 checks — MATCH
  :pkgcheck     PACKAGE MANAGER: 140 checks — MATCH
  :nodecheck    NODE LAYER: 421 checks — MATCH   (was 310)
  assembleDebug clean
  git status --porcelain swift | wc -l → 0

What the 111 new checks are:
  * `NodeSockets` driven directly against REAL peers — a plain JVM socket for
    the deterministic cases and real `node` for the two that matter: a node
    CLIENT against our server and our client against a node SERVER. "It works
    when both ends are ours" proves nothing about the wire.
  * the DNS wire format against a message built byte by byte WITH compression
    pointers (no JVM has a reference `dn_expand`), plus resolve{Txt,Mx,Ns}
    against real node's answers for the same live names.
  * the TLS transport's ORDER of events, not only its bytes — head,data,data,
    end. A fixture comparing the concatenated body passes just as happily
    against a transport that buffers everything.
  * `verify/neterrors` and `verify/reqsock`, read out of `verify/` and graded
    against the SAME checked-in node.txt iOS is graded against. Both match
    byte for byte.
  * `NodeSocketSmoke` — the shared program the DEVICE gate also runs.

One real bug the gate found: a server's handler was dropped on the server's
own close while its accepted sockets still routed through it, so
`server.close(cb)` never called back. A handler now goes only when the owner
AND every socket routed through it are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox added a commit that referenced this pull request Aug 5, 2026
Digests, HMAC, PBKDF2 and `randomUUID`. CryptoKit on iOS, the JCA here — the
platform's own either way, so invariant #4 holds without argument.

`NodeCrypto` is in `:node` rather than `:app` because none of it is framework:
`MessageDigest`, `Mac` and `SecretKeyFactory` are JDK classes Android also
ships, so it runs in a bare JVM harness. That is what lets it be graded
against REAL NODE computing the same digests rather than against pinned
vectors, and the difference matters here more than usual. A pinned vector
proves an algorithm was implemented; it does not prove OUR SPELLING reaches
the same function, and the spellings are the whole of the work:

  * node says `sha256`, CryptoKit says `SHA256`, the JCA says `SHA-256` — and
    its HMAC names have no separator at all.
  * `sha224` exists in the JCA and not in CryptoKit.
  * an empty HMAC key is legal to node and REJECTED by the JCA. It is padded
    to one zero byte here, which is exact rather than approximate: HMAC's inner
    padding makes a zero-length key and a single zero byte the same input.
  * `PBEKeySpec` takes CHARS, so a password byte over 0x7f would go through a
    charset and come out corrupted. It is mapped latin-1, the one encoding
    where a byte round-trips through a char.

53 checks, every message × every digest, HMAC across three key lengths
including empty, and PBKDF2 at 1000 rounds — each compared with what real node
answers for the same input. Proven able to fail: removing the empty-key
padding fails six of them by name.

The gate also caught a stale assertion on the way in, which is the third time
this pattern has fired: `NodeSmoke` probed `cryptoHash` as a DEFERRED surface,
so implementing it turned that check into an assertion that a regression had
happened. It probes `zlibOpen` now, and its comment says the name is expected
to keep moving — 3c did this to `netConnect`, 3d to `cryptoHash`.

STILL DEFERRED, and the line is not arbitrary. What remains needs KEY
MANAGEMENT — ciphers, EC/RSA/Ed25519 signing, ECDH, key generation — where iOS
rides Security framework and the JCA has no one-to-one counterpart, so it is a
port rather than a translation. Compression, `vm`, workers and children are
untouched.

  :screencheck  238 checks  MATCH
  :pkgcheck     140 checks  MATCH
  :nodecheck    483 checks  MATCH  (430 + 53)
  :shellcheck    25 scripts MATCH  /bin/sh
  on-device      66 checks  MATCH
  assembleDebug clean
  swift/ untouched

On device, against real node's own answers for the same input:

  ~ $ node hash.js
  sha256 b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
  hmac   0ba06f1f9a6300461e43454535dc3c42
  uuid   d7248d5c-bdf4-408a-bd7e-991e10028c39

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ThyFriendlyFox added a commit that referenced this pull request Aug 5, 2026
iOS drives zlib's own `z_stream`, where `windowBits` selects the framing for
free: 15 is the zlib wrapper, −15 raw, 15+16 gzip, 15+32 "whichever this is".
`java.util.zip` exposes exactly one of those choices — `Deflater(level,
nowrap)` — so the rest is written here:

  * gzip's 10-byte header and its CRC32/ISIZE trailer, little-endian, over the
    UNCOMPRESSED bytes. `GZIPOutputStream` would cover the one-shot case and
    cannot do it incrementally, so both paths share this framing rather than
    being two implementations that could disagree.
  * the auto-detect that `gunzip`, `inflate` and `unzip` all need, since iOS
    gives all three 15+32. Two bytes decide it, and a streaming coder that has
    only seen one HOLDS it rather than guessing — picking the wrong framing on
    one byte would be wrong for the whole stream.
  * SYNC_FLUSH while a deflating stream is live, or a chunk can sit inside the
    coder and a caller that pushed data and got nothing back reads it as a
    stall.

GRADED BOTH DIRECTIONS, which is the point: node decompresses what we compress
AND we decompress what node compressed. A round-trip through our own code
proves self-consistency, not correctness — a wrong frame passes it happily
while producing bytes no other implementation accepts. Proven exactly that
way: corrupting the gzip CRC fails only the cross-engine checks, because our
own inflater ignores the trailer. 50 checks, one-shot and streaming.

The stale-probe pattern fired a fourth time — `NodeSmoke` was probing
`zlibOpen` as deferred. It probes `brotliOpen` now, and that one is the LAST of
its kind here: brotli is not waiting on a milestone.

Brotli's refusal was also REWRITTEN, because the old one was no longer true.
It said "the Android host binds neither, and java.util.zip covers only half" —
half of which has just stopped being true. The reason now names the real wall:
Android decodes brotli inside its HTTP stack and exposes no codec to an app,
`java.util.zip` is DEFLATE only, and hand-writing it means carrying the static
dictionary too. A third-party artifact is what invariant #4 forbids.

  :screencheck  238 checks  MATCH
  :pkgcheck     140 checks  MATCH
  :nodecheck    533 checks  MATCH  (483 + 50)
  :shellcheck    25 scripts MATCH  /bin/sh
  on-device      66 checks  MATCH
  assembleDebug clean
  swift/ untouched

On device, against real node running the same script:

  ~ $ node gz.js
  plain 1000 gzip 49      ← real node: plain 1000 gzip 49
  roundtrip true
  magic 1f 8b

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants