OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 8c72c63

Fix renderer RPC reconnects for v0.7.1
8c72c63985abe172b0f6e1e042bd59477f4a64f8
by Local LLM <localllm@local> · 2026-07-16T13:36:11.000Z

Changed files (17) +480 -83

CHANGELOG.md +27 -0
……@@ -1,5 +1,32 @@
11# Changelog
22
3## v0.7.1 — 2026-07-16
4
5Corrective desktop transport release. The backend RPC handlers were healthy,
6but a renderer WebSocket interrupted during a reload or live-update handoff
7left the mounted UI writing into a dead pipe; unrelated Settings calls then
8surfaced as independent 30-second command timeouts.
9
10### Fixed
11
12- Pending renderer RPC calls now reject immediately when the WebSocket closes
13 or errors, and new calls fail clearly while the backend is reconnecting.
14- The authenticated renderer can reconnect during an eight-second grace period
15 without losing the backend, Corestore session, or buffered backend events.
16- Failed initial port candidates are explicitly destroyed, preventing a late
17 connection from claiming the single renderer slot.
18- The desktop shell now shows a real reconnect/resume screen and remounts the
19 application after the handshake is restored; exhausted retries give a safe
20 relaunch instruction without suggesting profile deletion.
21
22### Verification
23
24- The complete desktop suite passes `681/681`, including five new transport
25 lifecycle tests.
26- A controlled Pear renderer reload exercised the backend handoff and recorded
27 an authenticated reconnect inside the grace window. Afterward, identity,
28 profile, permissions, and relay RPCs all answered in `1–2 ms`.
29
330## v0.7.0 — 2026-07-16
431
532Swarm-distributed browser protections and extensions. Content Shield lists,
README.md +15 -15
……@@ -4,21 +4,21 @@ A local-first peer-to-peer browser, app store, search engine, naming layer, Nost
44
55**No accounts. No DNS. No manual app updates.** Sites and apps are addressed by stable Pear/Hyperdrive keys and pinned 24/7 on the [HiveRelay](https://github.com/bigdestiny2/P2P-Hiverelay) backbone. The publisher's laptop being offline doesn't matter — the relays carry the bytes, and users launch the current release from the catalogue without hunting for a download or applying an updater.
66
7**Current release:** `v0.7.0` · stable Pear production length `78006` (from `63165`). This release ships swarm-distributed Content Shield lists, capability-gated Pear Plugins, and a one-click P2P plugin catalogue. The default filter list, both example plugins, and the catalogue are published, pinned, and fresh-peer verified; their stable keys are in [the distribution guide](./filter-lists/README.md). The production drive is re-announced by its persistent publisher seeder, with live remote replication peers confirmed after release; the local cold-reader blob probe is a documented same-NAT false negative for this firewalled, history-heavy key. Desktop packages default to npm `latest` for HiveRelay, with the current dist-tag resolving to `0.20.2` and runtime compatibility checked through relay capability documents. See [docs/RELEASE_v0.7.0_OPERATOR.md](./docs/RELEASE_v0.7.0_OPERATOR.md) for the coordinated publish record.
7**Current release candidate:** `v0.7.1`. This corrective release makes the renderer/backend RPC transport survive reload and live-update handoffs: pending calls fail immediately on disconnect, the per-launch authenticated socket reconnects during a bounded grace window, and the shell visibly resumes instead of reporting unrelated 30-second command timeouts. It retains all `v0.7.0` Content Shield, Pear Plugins, and P2P catalogue features. See [docs/RELEASE_v0.7.1_OPERATOR.md](./docs/RELEASE_v0.7.1_OPERATOR.md) for the coordinated publish record.
88
99**Current architecture:** start with [docs/ARCHITECTURE_AND_CAPABILITIES.md](./docs/ARCHITECTURE_AND_CAPABILITIES.md). The deeper catalogue/search/naming/Nostr audit is in [docs/DEEP_AUDIT_CATALOG_SEARCH_NAMING_NOSTR_2026-06-21.md](./docs/DEEP_AUDIT_CATALOG_SEARCH_NAMING_NOSTR_2026-06-21.md).
1010
1111## Install it
1212
1313Primary desktop distribution is now native GitHub release packages. The
14`v0.7.0` release targets macOS, Windows, and Linux assets with SHA-256 sidecars
14`v0.7.1` release targets macOS, Windows, and Linux assets with SHA-256 sidecars
1515and platform manifests. Download from the
16[`v0.7.0` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.0),
16[`v0.7.1` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.1),
1717follow the [native install guide](./docs/INSTALL_NATIVE_PACKAGES.md), or resolve
1818the recommended asset for your machine from a source checkout:
1919
2020```sh
21npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
21npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
2222```
2323
2424The current package targets match the `cmake-pear` appling toolchain:
……@@ -224,29 +224,29 @@ node scripts/verify-live-catalog.js --expect-app peercord --expect-app peerit --
224224
225225## Distribution
226226
227The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.7.0` and `source_ref` set to the release commit to produce or refresh the attached release assets.
227The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.7.1` and `source_ref` set to the release commit to produce or refresh the attached release assets.
228228
229229Current generated artifacts are `.app.zip` on macOS, `.msix` on Windows, and `.AppImage` on Linux. The workflow uses `npm ci --prefix appling`, so update `appling/package-lock.json` deliberately when the native wrapper toolchain changes.
230230
231231```sh
232npm run check:appling-release -- --tag v0.7.0
232npm run check:appling-release -- --tag v0.7.1
233233npm run check:linux-appimage-metadata
234npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
235npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.7.0 --source-ref <release-commit>
236npm run -s generate:native-install-snippet -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
237npm run -s generate:native-install-guide -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
238npm run -s generate:native-install-smoke-plan -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit>
239npm run generate:package-manager-manifests -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof
234npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
235npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.7.1 --source-ref <release-commit>
236npm run -s generate:native-install-snippet -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
237npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
238npm run -s generate:native-install-smoke-plan -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit>
239npm run generate:package-manager-manifests -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof
240240npm run check:native-signing -- --require-public-trust --secret-source github --repo bigdestiny2/pearbrowser-desktop
241npm run check:public-trust-readiness -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
242npm run -s generate:public-trust-operator-report -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
241npm run check:public-trust-readiness -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
242npm run -s generate:public-trust-operator-report -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github
243243npm run -s generate:release-evidence-handoff
244244cd appling
245245npm ci
246246npm run generate
247247npm run build
248248cd ..
249npm run package:appling -- --tag v0.7.0
249npm run package:appling -- --tag v0.7.1
250250```
251251
252252Code signing is per-platform:
appling/CMakeLists.txt +1 -1
……@@ -332,7 +332,7 @@ add_pear_appling(
332332 pearbrowser_desktop
333333 ID "tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty"
334334 NAME "PearBrowser"
335 VERSION 0.7.0
335 VERSION 0.7.1
336336 AUTHOR "pearbrowser-desktop contributors"
337337 DESCRIPTION "P2P browser, app store, and publishing platform — no servers required."
338338
appling/assets/linux/io.github.bigdestiny2.pearbrowser.metainfo.xml +1 -0
……@@ -20,6 +20,7 @@
2020 </categories>
2121 <url type="homepage">https://github.com/bigdestiny2/pearbrowser-desktop</url>
2222 <releases>
23 <release version="0.7.1" date="2026-07-16"/>
2324 <release version="0.7.0" date="2026-07-16"/>
2425 <release version="0.6.1" date="2026-07-15"/>
2526 <release version="0.6.0" date="2026-07-13"/>
backend/catalogue-seed.js +1 -1
……@@ -26,7 +26,7 @@ module.exports = {
2626 "p2p"
2727 ],
2828 "description": "P2P browser, decentralized app store, and site publisher. macOS / Windows / Linux. Talks to peers directly over Hyperswarm. Pinned 24/7 on HiveRelay.",
29 "version": "0.7.0",
29 "version": "0.7.1",
3030 "homepage": "hyper://03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f/",
3131 "sourceUrl": "https://github.com/bigdestiny2/pearbrowser-desktop",
3232 "license": "MIT",
catalog-source/pearbrowser-network.catalog.json +2 -2
……@@ -1,7 +1,7 @@
11{
22 "name": "PearBrowser Network",
33 "description": "Curated entry point for the Pear ecosystem — the browser itself plus its companion sites.",
4 "version": 8,
4 "version": 9,
55 "publishedAt": "2026-07-16T00:00:00Z",
66 "publisher": "bigdestiny2",
77 "apps": [
……@@ -10,7 +10,7 @@
1010 "name": "PearBrowser Desktop",
1111 "description": "P2P browser, decentralized app store, and site publisher. macOS / Windows / Linux. Talks to peers directly over Hyperswarm. Pinned 24/7 on HiveRelay.",
1212 "author": "bigdestiny2",
13 "version": "0.7.0",
13 "version": "0.7.1",
1414 "categories": [
1515 "browser",
1616 "tools",
docs/INSTALL_NATIVE_PACKAGES.md +15 -15
……@@ -1,6 +1,6 @@
11# Install Native Packages
22
3Current release: `v0.7.0`.
3Current release: `v0.7.1`.
44
55These are package-proof desktop builds. Linux uses checksums only. macOS is
66ad-hoc signed but not notarized, and Windows packages are unsigned until the
……@@ -12,14 +12,14 @@ PearBrowser is free of malware until a signed and notarized DMG is published.
1212## Choose A Package
1313
1414Download directly from the
15[`v0.7.0` GitHub release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.0).
15[`v0.7.1` GitHub release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.1).
1616
1717| Machine | Recommended package | Checksum sidecar |
1818| --- | --- | --- |
19| macOS Apple Silicon | [PearBrowser-0.7.0-macos-arm64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-macos-arm64.app.zip) | [PearBrowser-0.7.0-macos-arm64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-macos-arm64.app.zip.sha256) |
20| macOS Intel | [PearBrowser-0.7.0-macos-x64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-macos-x64.app.zip) | [PearBrowser-0.7.0-macos-x64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-macos-x64.app.zip.sha256) |
21| Windows x64 | [PearBrowser-0.7.0-windows-x64.msix](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-windows-x64.msix) | [PearBrowser-0.7.0-windows-x64.msix.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-windows-x64.msix.sha256) |
22| Linux x64 | [PearBrowser-0.7.0-linux-x64.AppImage](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-linux-x64.AppImage) | [PearBrowser-0.7.0-linux-x64.AppImage.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.0/PearBrowser-0.7.0-linux-x64.AppImage.sha256) |
19| macOS Apple Silicon | [PearBrowser-0.7.1-macos-arm64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-arm64.app.zip) | [PearBrowser-0.7.1-macos-arm64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-arm64.app.zip.sha256) |
20| macOS Intel | [PearBrowser-0.7.1-macos-x64.app.zip](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-x64.app.zip) | [PearBrowser-0.7.1-macos-x64.app.zip.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-macos-x64.app.zip.sha256) |
21| Windows x64 | [PearBrowser-0.7.1-windows-x64.msix](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-windows-x64.msix) | [PearBrowser-0.7.1-windows-x64.msix.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-windows-x64.msix.sha256) |
22| Linux x64 | [PearBrowser-0.7.1-linux-x64.AppImage](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-linux-x64.AppImage) | [PearBrowser-0.7.1-linux-x64.AppImage.sha256](https://github.com/bigdestiny2/pearbrowser-desktop/releases/download/v0.7.1/PearBrowser-0.7.1-linux-x64.AppImage.sha256) |
2323
2424The Linux release contains exactly one product AppImage. Packaging now rejects
2525ambiguous AppImages and excludes build tools such as AppImageTool.
……@@ -31,26 +31,26 @@ native release workflow.
3131From a source checkout, ask the resolver for the current machine:
3232
3333```sh
34npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
34npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
3535```
3636
3737Or specify a target:
3838
3939```sh
40npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --platform macos --arch x64
40npm run resolve:native-release -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --platform macos --arch x64
4141```
4242
4343Release operators can verify every recommended package download and checksum
4444sidecar in one pass:
4545
4646```sh
47npm run verify:native-downloads -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --all
47npm run verify:native-downloads -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop --all
4848```
4949
5050Release operators can regenerate this guide from the same resolver rules:
5151
5252```sh
53npm run -s generate:native-install-guide -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop
53npm run -s generate:native-install-guide -- --tag v0.7.1 --repo bigdestiny2/pearbrowser-desktop
5454```
5555
5656## Verify The Download
……@@ -58,7 +58,7 @@ npm run -s generate:native-install-guide -- --tag v0.7.0 --repo bigdestiny2/pear
5858macOS and Linux:
5959
6060```sh
61shasum -a 256 -c PearBrowser-0.7.0-macos-arm64.app.zip.sha256
61shasum -a 256 -c PearBrowser-0.7.1-macos-arm64.app.zip.sha256
6262```
6363
6464Use the matching filename for your package. A passing check prints `OK`.
……@@ -66,7 +66,7 @@ Use the matching filename for your package. A passing check prints `OK`.
6666Windows PowerShell:
6767
6868```powershell
69$package = "PearBrowser-0.7.0-windows-x64.msix"
69$package = "PearBrowser-0.7.1-windows-x64.msix"
7070$expected = (Get-Content "$($package).sha256").Split(" ")[0].ToLowerInvariant()
7171$actual = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant()
7272if ($actual -ne $expected) { throw "SHA-256 mismatch for $package" }
……@@ -88,7 +88,7 @@ macOS:
8888
8989Windows:
9090
911. Install `PearBrowser-0.7.0-windows-x64.msix`.
911. Install `PearBrowser-0.7.1-windows-x64.msix`.
92922. For package-proof builds, Windows SmartScreen may warn because the installer
9393 is not yet Authenticode-signed. Continue only if you intentionally trust this
9494 package and its checksum.
……@@ -96,8 +96,8 @@ Windows:
9696Linux:
9797
9898```sh
99chmod +x PearBrowser-0.7.0-linux-x64.AppImage
100./PearBrowser-0.7.0-linux-x64.AppImage
99chmod +x PearBrowser-0.7.1-linux-x64.AppImage
100./PearBrowser-0.7.1-linux-x64.AppImage
101101```
102102
103103## Recovery Fallback
docs/RELEASE_NOTES_v0.7.1.md +31 -0
……@@ -0,0 +1,31 @@
1# PearBrowser Desktop v0.7.1
2
3PearBrowser `v0.7.1` is a corrective transport release for the desktop browser.
4It fixes the cluster of `RPC timeout` messages that could appear in Settings
5after a renderer reload or live-update handoff even though the identity,
6profile, permissions, and relay handlers were healthy.
7
8What changed:
9
10- Pending RPC calls fail immediately when the local renderer socket closes,
11 instead of waiting 30 seconds and blaming an unrelated command number.
12- The per-launch authenticated renderer automatically reconnects during a
13 bounded eight-second backend grace period.
14- Backend events are buffered across that brief handoff and the shell displays
15 an explicit reconnect/resume screen.
16- Failed initial port candidates are destroyed so a late socket cannot claim
17 the browser's single renderer slot.
18- If automatic recovery is impossible, PearBrowser gives a clear full-relaunch
19 instruction without asking users to delete their profile or app storage.
20
21The full desktop suite passes `681/681`. A controlled Pear renderer reload also
22completed the authenticated reconnect path end to end; the four originally
23reported Settings calls then answered in `1–2 ms`.
24
25All browser protection and extension features from `v0.7.0` remain included:
26P2P Content Shield lists, capability-gated Pear Plugins, offline list restore,
27and the one-click P2P plugin catalogue.
28
29The stable Pear address continues to hot-sync the current app:
30
31`pear://tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty`
docs/RELEASE_v0.7.1_OPERATOR.md +53 -0
……@@ -0,0 +1,53 @@
1# PearBrowser Desktop v0.7.1 — release runbook
2
3Date: 2026-07-16
4Tag: `v0.7.1`
5Repo: `bigdestiny2/pearbrowser-desktop`
6Production link: `pear://tco5k7h38uoxatedp1wongdbhjxow1x7jiwm3t1i9cujbebhsbty`
7Catalogue key: `f5fb7500bccd60a976d2b1d24246108f4444a210b9ca591533114dffc089934d`
8Website Hyperdrive key: `03f0060a35451cfb6b68ad1dda1b8474ebb43fd9100071ccf7d67679a83ebb4f`
9
10This corrective release is complete only when the source, stable Pear bytes,
11live catalogue, GitHub release/assets, `pearbrowser.com`, and website
12Hyperdrive mirror all advertise `v0.7.1` and pass their fresh-reader checks.
13
14## Fix anchors
15
16- Renderer RPC rejects all pending calls immediately on socket close/error.
17- The authenticated renderer retries with bounded backoff while the backend
18 preserves its Corestore and event buffer for eight seconds.
19- The reconnect screen remounts the app only after the RPC handshake returns.
20- Failed initial WebSocket candidates are destroyed before scanning the next
21 RPC port.
22
23## Verified candidate evidence
24
25- Focused transport and packaging tests: `64/64`.
26- Complete desktop suite: `681/681`.
27- Runtime smoke: DHT connected, proxy ready, 21 HiveRelay connections.
28- Controlled renderer reload: replacement backend/port handoff observed,
29 followed by `renderer reconnected within grace period`.
30- Post-reload RPCs: commands `31`, `80`, `83`, and `40` all passed in `1–2 ms`.
31
32## Coordinated publication order
33
341. Merge and tag the tested source candidate.
352. Publish the stable Pear production link with `./scripts/release-prod.sh`.
363. Publish catalogue version `9` with PearBrowser `0.7.1` and verify it from a
37 fresh reader.
384. Create the GitHub `v0.7.1` release from
39 `docs/RELEASE_NOTES_v0.7.1.md`, run package-proof native builds, and verify
40 every attached asset against its SHA-256 sidecar.
415. Update/deploy `pearbrowser.com`, then publish and fresh-read the tracked-only
42 website Hyperdrive mirror.
436. Record exact source refs, drive lengths, workflow URLs, and website evidence
44 below.
45
46## Final record
47
48- Source merge/tag: pending
49- Stable Pear length: pending (from `78006`)
50- Live catalogue length/version: pending (from length `307`, version `8`)
51- GitHub release/workflow: pending
52- Website deploy: pending
53- Website Hyperdrive length: pending (from `126`)
index.js +25 -7
……@@ -25,6 +25,7 @@ const { authorizeRpcWebSocket, DiagnosticRpcRouter } = rpcWebSocketAuth
2525// grabs the next port instead of failing with EADDRINUSE.
2626const RPC_PORT_BASE = 9876
2727const RPC_PORT_COUNT = 5
28const RENDERER_RECONNECT_GRACE_MS = 8000
2829const rpcSessionToken = String(Pear.config?.startId || '')
2930const diagnosticToken = String(env.PEARBROWSER_RPC_DIAGNOSTIC_TOKEN || '')
3031if (!rpcSessionToken) throw new Error('Pear runtime did not provide a per-launch RPC session token')
……@@ -105,6 +106,7 @@ try {
105106// Events emitted by the backend before the renderer connects are
106107// buffered here so nothing boot-time is missed.
107108let client = null
109let rendererDisconnectTimer = null
108110const diagnostics = new Set()
109111const eventBuffer = []
110112const diagnosticRouter = new DiagnosticRpcRouter({
……@@ -183,6 +185,11 @@ const onSocket = (socket, req) => {
183185 console.log('[rpc] rejecting extra WS connection')
184186 return socket.end()
185187 }
188 if (rendererDisconnectTimer) {
189 clearTimeout(rendererDisconnectTimer)
190 rendererDisconnectTimer = null
191 console.log('[rpc] renderer reconnected within grace period')
192 }
186193 console.log('[rpc] renderer connected')
187194 client = socket
188195
……@@ -208,19 +215,28 @@ const onSocket = (socket, req) => {
208215 socket.on('data', (data) => backendPipe.write(data))
209216 socket.on('close', () => {
210217 console.log('[rpc] renderer disconnected')
211 if (client === socket) client = null
212 // The WS socket closing is the most reliable signal that the
213 // user closed the window. Pipe events from runtime.start and
214 // Pear.teardown proved unreliable in practice. Tear down the
215 // main process here so the next launch doesn't see a zombie.
216 teardown('renderer-ws-close')
218 detachRenderer(socket)
217219 })
218220 socket.on('error', (err) => {
219221 console.error('[rpc] socket error:', err.message)
220 if (client === socket) client = null
222 detachRenderer(socket)
221223 })
222224}
223225
226function detachRenderer (socket) {
227 if (client !== socket) return
228 client = null
229 if (rendererDisconnectTimer) clearTimeout(rendererDisconnectTimer)
230 // A renderer reload or brief localhost socket interruption can reconnect
231 // with the same per-launch token. Preserve the backend and buffered events
232 // briefly; a genuine window close still tears down before it can become a
233 // zombie that holds the Corestore lock or RPC port.
234 rendererDisconnectTimer = setTimeout(() => {
235 rendererDisconnectTimer = null
236 if (!client) teardown('renderer-ws-close')
237 }, RENDERER_RECONNECT_GRACE_MS)
238}
239
224240let rpcServer = null
225241let rpcPort = null
226242function listenRpcServer (port) {
……@@ -293,6 +309,8 @@ function teardown (reason) {
293309 if (tornDown) return
294310 tornDown = true
295311 console.log('[teardown] triggered by', reason)
312 if (rendererDisconnectTimer) clearTimeout(rendererDisconnectTimer)
313 rendererDisconnectTimer = null
296314 try { rpcServer?.close() } catch {}
297315 try { client?.end?.() } catch {}
298316 try { backendPipe.end?.() } catch {}
package-lock.json +2 -2
……@@ -1,12 +1,12 @@
11{
22 "name": "pearbrowser-desktop",
3 "version": "0.7.0",
3 "version": "0.7.1",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "pearbrowser-desktop",
9 "version": "0.7.0",
9 "version": "0.7.1",
1010 "hasInstallScript": true,
1111 "license": "Apache-2.0 AND MIT",
1212 "dependencies": {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pearbrowser-desktop",
3 "version": "0.7.0",
3 "version": "0.7.1",
44 "description": "P2P browser + decentralized app store + site builder, desktop Pear fork of PearBrowser (jj10133/Vinjari family)",
55 "license": "Apache-2.0 AND MIT",
66 "type": "module",
test/release-packaging.test.js +4 -0
……@@ -2185,6 +2185,10 @@ test('runtime smoke is authenticated, status-only by default, and does not becom
21852185 assert.match(mainEntry, /diagnostics\.add\(socket\)/)
21862186 assert.match(mainEntry, /diagnostics\.delete\(socket\)/)
21872187 assert.match(mainEntry, /teardown\('renderer-ws-close'\)/)
2188 assert.match(mainEntry, /RENDERER_RECONNECT_GRACE_MS/)
2189 assert.match(mainEntry, /renderer reconnected within grace period/)
2190 assert.match(bootEntry, /enableReconnect/)
2191 assert.match(bootEntry, /reconnect-failed/)
21882192 assert.doesNotMatch(mainEntry.match(/const onDiagnosticSocket[\s\S]*?\n}\n\nconst onSocket/)?.[0] || '', /teardown\(/)
21892193})
21902194
test/rpc-client.test.js +127 -0
……@@ -0,0 +1,127 @@
1import test from 'node:test'
2import assert from 'node:assert/strict'
3import { EventEmitter } from 'node:events'
4import { RpcClient } from '../ui/rpc-client.js'
5import { WsPipe } from '../ui/boot.js'
6
7class MockPipe extends EventEmitter {
8 constructor ({ connected = true, writable = true } = {}) {
9 super()
10 this.connected = connected
11 this.writable = writable
12 this.frames = []
13 }
14
15 write (frame) {
16 if (!this.writable) return false
17 this.frames.push(frame)
18 return true
19 }
20}
21
22test('renderer RPC rejects pending requests immediately when the pipe closes', async () => {
23 const pipe = new MockPipe()
24 const rpc = new RpcClient(pipe)
25 const pending = rpc.request(31, {}, 10_000)
26
27 pipe.emit('close')
28
29 await assert.rejects(pending, /RPC connection closed/)
30 assert.equal(rpc._pending.size, 0)
31})
32
33test('renderer RPC rejects new requests while the backend is disconnected', async () => {
34 const pipe = new MockPipe({ connected: false })
35 const rpc = new RpcClient(pipe)
36
37 await assert.rejects(rpc.request(80), /RPC unavailable: 80 .*reconnecting/)
38 assert.equal(pipe.frames.length, 0)
39})
40
41test('renderer RPC resumes requests after the pipe reconnects', async () => {
42 const pipe = new MockPipe({ connected: false })
43 const rpc = new RpcClient(pipe)
44
45 pipe.connected = true
46 pipe.emit('open')
47 const pending = rpc.request(40, {}, 1_000)
48 assert.equal(pipe.frames.length, 1)
49
50 const request = JSON.parse(pipe.frames[0].slice(8))
51 pipe.emit('data', frame({ id: request.id, result: { configured: true } }))
52
53 assert.deepEqual(await pending, { configured: true })
54})
55
56test('renderer RPC rejects a request when the pipe is not writable', async () => {
57 const pipe = new MockPipe({ writable: false })
58 const rpc = new RpcClient(pipe)
59
60 await assert.rejects(rpc.request(83), /RPC connection is not writable/)
61 assert.equal(rpc._pending.size, 0)
62})
63
64test('renderer WebSocket reconnects and becomes writable again', async (t) => {
65 const previousWebSocket = globalThis.WebSocket
66 globalThis.WebSocket = FakeWebSocket
67 FakeWebSocket.instances.length = 0
68 t.after(() => { globalThis.WebSocket = previousWebSocket })
69
70 const pipe = new WsPipe('ws://127.0.0.1:9876/?session=test', {
71 reconnectBaseMs: 1,
72 reconnectMaxMs: 1,
73 maxReconnectAttempts: 2
74 })
75 t.after(() => pipe.destroy())
76
77 const first = FakeWebSocket.instances[0]
78 first.open()
79 pipe.enableReconnect()
80 assert.equal(pipe.connected, true)
81
82 first.disconnect()
83 assert.equal(pipe.connected, false)
84 await new Promise(resolve => setTimeout(resolve, 5))
85
86 const second = FakeWebSocket.instances[1]
87 assert.ok(second, 'a replacement WebSocket should be created')
88 second.open()
89 assert.equal(pipe.connected, true)
90 assert.equal(pipe.write('frame'), true)
91 assert.deepEqual(second.sent, ['frame'])
92})
93
94function frame (message) {
95 const json = JSON.stringify(message)
96 return json.length.toString(16).padStart(8, '0') + json
97}
98
99class FakeWebSocket extends EventTarget {
100 static instances = []
101
102 constructor (url) {
103 super()
104 this.url = url
105 this.binaryType = ''
106 this.sent = []
107 this.closed = false
108 FakeWebSocket.instances.push(this)
109 }
110
111 open () {
112 this.dispatchEvent(new Event('open'))
113 }
114
115 disconnect () {
116 this.dispatchEvent(new Event('close'))
117 }
118
119 send (frame) {
120 if (this.closed) throw new Error('socket closed')
121 this.sent.push(frame)
122 }
123
124 close () {
125 this.closed = true
126 }
127}
ui/boot.js +111 -23
……@@ -179,49 +179,125 @@ const C = {
179179 EVT_ASK_BROWSER_STREAM: 111
180180}
181181
182class WsPipe {
183 constructor (url) {
184 this._listeners = { data: [], close: [], error: [], open: [] }
182export class WsPipe {
183 constructor (url, opts = {}) {
184 this._listeners = { data: [], close: [], error: [], open: [], reconnecting: [], 'reconnect-failed': [] }
185 this._url = url
185186 this._connected = false
186 this._outgoing = []
187 console.log('[ws] connecting to', url)
188 this._ws = new WebSocket(url)
189 this._ws.binaryType = 'arraybuffer'
187 this._connecting = false
188 this._destroyed = false
189 this._reconnectEnabled = false
190 this._reconnectTimer = null
191 this._reconnectAttempt = 0
192 this._maxReconnectAttempts = Number.isInteger(opts.maxReconnectAttempts) ? opts.maxReconnectAttempts : 8
193 this._reconnectBaseMs = Number.isFinite(opts.reconnectBaseMs) ? opts.reconnectBaseMs : 100
194 this._reconnectMaxMs = Number.isFinite(opts.reconnectMaxMs) ? opts.reconnectMaxMs : 1000
195 this._failedSocket = null
196 this._ws = null
197 this._connect()
198 }
199
200 get connected () {
201 return this._connected
202 }
203
204 enableReconnect () {
205 if (this._destroyed) return
206 this._reconnectEnabled = true
207 if (!this._connected && !this._connecting) this._scheduleReconnect()
208 }
209
210 destroy () {
211 this._destroyed = true
212 this._reconnectEnabled = false
213 this._connected = false
214 this._connecting = false
215 if (this._reconnectTimer) clearTimeout(this._reconnectTimer)
216 this._reconnectTimer = null
217 const socket = this._ws
218 this._ws = null
219 try { socket?.close() } catch {}
220 }
221
222 _emit (event, detail) {
223 for (const fn of this._listeners[event] || []) fn(detail)
224 }
225
226 _connect () {
227 if (this._destroyed || this._connecting || this._connected) return
228 this._connecting = true
229 console.log('[ws] connecting to', this._url)
230 const socket = new WebSocket(this._url)
231 this._ws = socket
232 this._failedSocket = null
233 socket.binaryType = 'arraybuffer'
190234
191 this._ws.addEventListener('open', () => {
235 socket.addEventListener('open', () => {
236 if (this._destroyed || socket !== this._ws) {
237 try { socket.close() } catch {}
238 return
239 }
192240 console.log('[ws] open')
241 this._connecting = false
193242 this._connected = true
194 for (const fn of this._listeners.open) fn()
195 for (const frame of this._outgoing) this._ws.send(frame)
196 this._outgoing.length = 0
243 this._reconnectAttempt = 0
244 this._emit('open')
197245 })
198246
199 this._ws.addEventListener('message', (e) => {
247 socket.addEventListener('message', (e) => {
248 if (socket !== this._ws || !this._connected) return
200249 const text = typeof e.data === 'string'
201250 ? e.data
202251 : new TextDecoder().decode(e.data)
203 for (const fn of this._listeners.data) fn(text)
252 this._emit('data', text)
204253 })
205254
206 this._ws.addEventListener('close', (e) => {
255 socket.addEventListener('close', (e) => {
207256 console.log('[ws] close', e.code, e.reason)
208 for (const fn of this._listeners.close) fn()
257 this._handleDisconnect(socket)
209258 })
210259
211 this._ws.addEventListener('error', (e) => {
260 socket.addEventListener('error', (e) => {
212261 console.error('[ws] error', e)
213 for (const fn of this._listeners.error) fn(e)
262 this._handleDisconnect(socket, e)
263 try { socket.close() } catch {}
214264 })
215265 }
216266
267 _handleDisconnect (socket, error = null) {
268 if (this._destroyed || socket !== this._ws || this._failedSocket === socket) return
269 this._failedSocket = socket
270 this._connected = false
271 this._connecting = false
272 if (error) this._emit('error', error)
273 this._emit('close')
274 this._scheduleReconnect()
275 }
276
277 _scheduleReconnect () {
278 if (!this._reconnectEnabled || this._destroyed || this._connected || this._connecting || this._reconnectTimer) return
279 if (this._reconnectAttempt >= this._maxReconnectAttempts) {
280 this._emit('reconnect-failed', { attempts: this._reconnectAttempt })
281 return
282 }
283 const attempt = ++this._reconnectAttempt
284 const delay = Math.min(this._reconnectBaseMs * (2 ** (attempt - 1)), this._reconnectMaxMs)
285 this._emit('reconnecting', { attempt, delay })
286 this._reconnectTimer = setTimeout(() => {
287 this._reconnectTimer = null
288 this._connect()
289 }, delay)
290 }
291
217292 on (event, fn) {
218293 if (this._listeners[event]) this._listeners[event].push(fn)
219294 return this
220295 }
221296
222297 write (frame) {
223 if (this._connected) this._ws.send(frame)
224 else this._outgoing.push(frame)
298 if (!this._connected || !this._ws) throw new Error('WebSocket RPC connection is not open')
299 this._ws.send(frame)
300 return true
225301 }
226302}
227303
……@@ -301,10 +377,22 @@ function tryConnect (url, timeoutMs) {
301377 return probeBackend(url, timeoutMs).then(() => new Promise((resolve, reject) => {
302378 const pipe = new WsPipe(url)
303379 let settled = false
304 const t = setTimeout(() => { if (!settled) { settled = true; reject(new Error('timeout')) } }, timeoutMs)
305 pipe.on('open', () => { if (!settled) { settled = true; clearTimeout(t); resolve(pipe) } })
306 pipe.on('error', () => { if (!settled) { settled = true; clearTimeout(t); reject(new Error('ws error')) } })
307 pipe.on('close', () => { if (!settled) { settled = true; clearTimeout(t); reject(new Error('ws closed')) } })
380 const finish = (err = null) => {
381 if (settled) return
382 settled = true
383 clearTimeout(t)
384 if (err) {
385 pipe.destroy()
386 reject(err)
387 } else {
388 pipe.enableReconnect()
389 resolve(pipe)
390 }
391 }
392 const t = setTimeout(() => finish(new Error('timeout')), timeoutMs)
393 pipe.on('open', () => finish())
394 pipe.on('error', () => finish(new Error('ws error')))
395 pipe.on('close', () => finish(new Error('ws closed')))
308396 }))
309397}
310398
ui/main.js +29 -12
……@@ -54,25 +54,42 @@ try {
5454 failed=${true} />`)
5555 })
5656
57 // Only steal the splash screen for pre-mount failures. After the
58 // App mounts, the status pill at the bottom of the real UI handles
59 // disconnect/reconnect state without yanking the user to a splash.
57 // A renderer reload or transient local WebSocket failure should not leave
58 // the mounted app issuing requests into a dead pipe. Show an explicit
59 // reconnect state and remount once the authenticated socket is restored.
6060 let mounted = false
61 const mountApp = () => {
62 if (bootFailedShown || mounted || !pipe.connected) return
63 mounted = true
64 root.render(html`<${App} rpc=${rpc} C=${C} storagePath=${storagePath} />`)
65 }
6166 pipe.on('open', () => {
62 if (!mounted && !bootFailedShown) root.render(html`<${Splash} message="Handshake OK · waiting for DHT…" />`)
67 if (bootFailedShown) return
68 root.render(html`<${Splash} message="Handshake restored · resuming…" />`)
69 setTimeout(mountApp, 50)
6370 })
64 pipe.on('error', () => {
65 if (!mounted && !bootFailedShown) root.render(html`<${Splash} message="Backend unreachable" detail="ws://127.0.0.1:9876 — is the main process running?" failed=${true} />`)
71 pipe.on('error', (err) => {
72 console.error('Backend RPC connection error:', err)
6673 })
6774 pipe.on('close', () => {
68 if (!mounted && !bootFailedShown) root.render(html`<${Splash} message="Backend disconnected" detail="The WebSocket closed unexpectedly. Restart the app." failed=${true} />`)
75 if (bootFailedShown) return
76 mounted = false
77 root.render(html`<${Splash} message="Backend connection lost · reconnecting…" />`)
78 })
79 pipe.on('reconnecting', ({ attempt } = {}) => {
80 if (bootFailedShown) return
81 root.render(html`<${Splash} message=${`Reconnecting to backend${attempt ? ` · attempt ${attempt}` : ''}…`} />`)
82 })
83 pipe.on('reconnect-failed', () => {
84 if (bootFailedShown) return
85 mounted = false
86 root.render(html`<${Splash}
87 message="Backend disconnected"
88 detail="Automatic reconnect failed. Fully quit and relaunch PearBrowser; your profile and application storage are safe."
89 failed=${true} />`)
6990 })
7091
71 setTimeout(() => {
72 if (bootFailedShown) return // never mount the app on a dead backend
73 mounted = true
74 root.render(html`<${App} rpc=${rpc} C=${C} storagePath=${storagePath} />`)
75 }, 250)
92 setTimeout(mountApp, 250)
7693} catch (err) {
7794 console.error('Boot failed:', err)
7895 root.render(html`<${Splash} message="Boot failed" detail=${err.stack || err.message} failed=${true} />`)
ui/rpc-client.js +35 -4
……@@ -13,16 +13,30 @@ export class RpcClient extends EventTarget {
1313 this._nextId = 1
1414 this._pending = new Map()
1515 this._buffer = ''
16 this._connected = pipe.connected !== false
1617
1718 pipe.on('data', (chunk) => this._onData(chunk))
18 pipe.on('close', () => this.dispatchEvent(new CustomEvent('close')))
19 pipe.on('error', (err) => this.dispatchEvent(new CustomEvent('error', { detail: err })))
19 pipe.on('open', () => {
20 this._connected = true
21 this.dispatchEvent(new CustomEvent('open'))
22 })
23 pipe.on('close', () => {
24 this._disconnect('RPC connection closed')
25 this.dispatchEvent(new CustomEvent('close'))
26 })
27 pipe.on('error', (err) => {
28 this._disconnect('RPC connection failed')
29 this.dispatchEvent(new CustomEvent('error', { detail: err }))
30 })
2031 }
2132
2233 request (cmd, data = {}, timeout = 30000) {
2334 if (cmd === undefined || cmd === null) {
2435 return Promise.reject(new Error('RPC command is missing. Renderer constants are out of sync with backend/constants.js.'))
2536 }
37 if (!this._connected) {
38 return Promise.reject(new Error(`RPC unavailable: ${cmd} (backend disconnected; reconnecting)`))
39 }
2640
2741 return new Promise((resolve, reject) => {
2842 const id = this._nextId++
……@@ -33,7 +47,13 @@ export class RpcClient extends EventTarget {
3347 }
3448 }, timeout)
3549 this._pending.set(id, { resolve, reject, timer })
36 this._send({ id, cmd, data })
50 try {
51 this._send({ id, cmd, data })
52 } catch (err) {
53 clearTimeout(timer)
54 this._pending.delete(id)
55 reject(err)
56 }
3757 })
3858 }
3959
……@@ -45,7 +65,18 @@ export class RpcClient extends EventTarget {
4565 _send (msg) {
4666 const json = JSON.stringify(msg)
4767 const frame = json.length.toString(16).padStart(8, '0') + json
48 this._pipe.write(frame)
68 if (this._pipe.write(frame) === false) {
69 throw new Error('RPC connection is not writable')
70 }
71 }
72
73 _disconnect (message) {
74 this._connected = false
75 for (const pending of this._pending.values()) {
76 clearTimeout(pending.timer)
77 pending.reject(new Error(message))
78 }
79 this._pending.clear()
4980 }
5081
5182 _onData (chunk) {