From 1cb6f2898216c68c515da347879f5c3c75ddcf30 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 5 Aug 2026 08:18:58 -0600 Subject: [PATCH 1/4] fix(ci): keep READ-ME-FIRST under release/ for packaging smoke Mixing release-warnings into upload-artifact paths changed the LCA so installers nested as release/release/*.exe and broke NSIS smoke downloads. --- .github/workflows/build.yaml | 16 +++++++++++++--- docs/ci-cd.md | 2 +- scripts/test-win-nsis-install.mjs | 1 + src/main/windows-packaging.contract.test.ts | 8 ++++++++ 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 882eabbb1..f3527ca53 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -144,6 +144,16 @@ jobs: APPLE_TEAM_ID: ${{ matrix.os == 'macos-latest' && secrets.APPLE_TEAM_ID || '' }} run: ${{ matrix.build_script }} + # Stage under release/ so upload-artifact@v7's least-common-ancestor stays + # release/ (paths outside release/ nest installers as release/release/*.exe and + # break packaging-smoke, which downloads to path: release). + - name: Stage READ-ME-FIRST into release output + shell: bash + run: | + set -euo pipefail + mkdir -p release + cp release-warnings/READ-ME-FIRST-test-build.md release/READ-ME-FIRST-test-build.md + # Do not upload raw Mesh-client.app — upload-artifact@v7 dereferences framework # symlinks (~3× Electron Framework size). DMG/ZIP preserve correct layout. - name: Upload macOS Artifact @@ -160,7 +170,7 @@ jobs: release/mac-universal/**/*.zip release/*.dmg release/*.zip - release-warnings/READ-ME-FIRST-test-build.md + release/READ-ME-FIRST-test-build.md retention-days: 30 - name: Upload Linux Artifact @@ -172,7 +182,7 @@ jobs: release/*.AppImage release/*.deb release/*.rpm - release-warnings/READ-ME-FIRST-test-build.md + release/READ-ME-FIRST-test-build.md retention-days: 30 - name: Upload Windows Artifact @@ -183,7 +193,7 @@ jobs: path: | # Per-arch NSIS installers: Mesh-client Setup {version}.exe + {version}-arm64.exe release/*.exe - release-warnings/READ-ME-FIRST-test-build.md + release/READ-ME-FIRST-test-build.md retention-days: 30 packaging-smoke: diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 60add661d..1fde63cbc 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -316,7 +316,7 @@ CI focuses on lint, typecheck, build, Flatpak metadata validation, and coverage 1. Labels **Build Binaries** / **Build Flatpak (no release)** runs as a **test build** (not an official release) in `$GITHUB_STEP_SUMMARY` 2. Compares this tree’s `CURRENT_SCHEMA_VERSION` to the last published (non-draft) GitHub Release tag -3. Uploads `READ-ME-FIRST-test-build.md` (build) / `READ-ME-FIRST-flatpak.md` (flatpak) / `READ-ME-FIRST-schema.md` (release) and includes the warning in platform / Flatpak artifact uploads when sharing Actions downloads +3. Uploads `READ-ME-FIRST-test-build.md` (build) / `READ-ME-FIRST-flatpak.md` (flatpak) / `READ-ME-FIRST-schema.md` (release). **Build Binaries** stages the note into `release/` before platform uploads so `upload-artifact`’s least-common-ancestor stays under `release/` (mixing `release-warnings/` nests installers as `release/release/*.exe` and breaks `packaging-smoke`). Flatpak keeps a separate per-arch `flatpak-schema-warning-*` artifact beside the bundle 4. Exposes `schema_bumped` / `curr_schema` / `prev_schema` / `prev_tag` for packaging When schema is bumped, packaging runs `scripts/write-schema-upgrade-notice.mjs` so Windows NSIS can show a MessageBox and macOS/Linux/Flatpak bundles can include `SCHEMA-UPGRADE.txt` in app resources (`electron-builder-before-pack.mjs` / Flatpak `resources/` copy). diff --git a/scripts/test-win-nsis-install.mjs b/scripts/test-win-nsis-install.mjs index 833655d03..4f6bb5fbe 100644 --- a/scripts/test-win-nsis-install.mjs +++ b/scripts/test-win-nsis-install.mjs @@ -154,6 +154,7 @@ function main(arch, probe7z) { const installer = installerName(version, arch); const installerPath = path.join(releaseDir, installer); if (!existsSync(installerPath)) { + dumpDir('release dir (installer missing)', releaseDir, 2); fail(`Installer not found: ${installerPath}`); } diff --git a/src/main/windows-packaging.contract.test.ts b/src/main/windows-packaging.contract.test.ts index e01cf3174..a494df171 100644 --- a/src/main/windows-packaging.contract.test.ts +++ b/src/main/windows-packaging.contract.test.ts @@ -130,6 +130,7 @@ describe('Windows packaging (contract)', () => { expect(installScript).toContain('assertBundledReticulumSidecarInBundle'); expect(installScript).toContain('/LOG='); expect(installScript).toContain('find-nsis-app-archive.mjs'); + expect(installScript).toContain("dumpDir('release dir (installer missing)'"); const finderScript = readFileSync( join(REPO_ROOT, 'scripts', 'find-nsis-app-archive.mjs'), @@ -156,6 +157,13 @@ describe('Windows packaging (contract)', () => { ); expect(buildWorkflow).toContain('needs: build'); expect(buildWorkflow).not.toContain('win-arm64-install:'); + // READ-ME-FIRST must live under release/ in uploads so artifact LCA stays release/ + // (paths outside release/ nest as release/release/*.exe and break packaging-smoke). + expect(buildWorkflow).toContain('Stage READ-ME-FIRST into release output'); + expect(buildWorkflow).toContain('release/READ-ME-FIRST-test-build.md'); + expect(buildWorkflow).not.toMatch( + /Upload Windows Artifact[\s\S]*release-warnings\/READ-ME-FIRST-test-build\.md/, + ); const buildJobBlock = buildWorkflow.slice( buildWorkflow.indexOf(' build:'), From ad93cd0417575731e031f4edefdd4b69d89bad7b Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 5 Aug 2026 08:54:28 -0600 Subject: [PATCH 2/4] fix: single-owner RF reconnect controller for MeshCore/Meshtastic TCP Stop connection-lost from scheduling after await disconnect while a reconnect cycle is already active (n7eal dual attempt 2+3). Shared createRfReconnectController owns dirty/generation; TCP device_status no longer double-enters lost; DMG Applications link named explicitly. --- electron-builder.yml | 2 + .../hooks/meshcore/meshcoreConnSideEffects.ts | 8 +- .../lib/rfReconnectController.test.ts | 151 +++++++++++++ src/renderer/lib/rfReconnectController.ts | 213 ++++++++++++++++++ .../loraRfReconnectParity.contract.test.ts | 55 +++-- .../useMeshcoreRuntime.reconnect.test.ts | 22 +- src/renderer/runtime/useMeshcoreRuntime.ts | 60 +++-- ...htasticRuntime.reconnect-hardening.test.ts | 17 +- src/renderer/runtime/useMeshtasticRuntime.ts | 59 +++-- 9 files changed, 495 insertions(+), 92 deletions(-) create mode 100644 src/renderer/lib/rfReconnectController.test.ts create mode 100644 src/renderer/lib/rfReconnectController.ts diff --git a/electron-builder.yml b/electron-builder.yml index 87ab4e777..91c36a0eb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -62,10 +62,12 @@ dmg: contents: - x: 130 y: 220 + type: file - x: 410 y: 220 type: link path: /Applications + name: Applications linux: target: - target: AppImage diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index 782356e78..fab56b553 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -683,7 +683,13 @@ export function attachMeshcoreConnSideEffects( console.debug('[meshcoreConnSideEffects] stale conn close ' + errLikeToLogString(e)); }); } - if (shouldReconnect && !meshcoreExplicitDisconnectRef.current) { + if ( + shouldReconnect && + !meshcoreExplicitDisconnectRef.current && + // TCP reconnect is owned by the runtime meshcore.tcp.onDisconnected listener (#792). + // Calling lost here as well double-entered the scheduler (n7eal dual backoff). + meshcoreConnectTypeRef.current !== 'tcp' + ) { handleConnectionLostRef.current(); } }); diff --git a/src/renderer/lib/rfReconnectController.test.ts b/src/renderer/lib/rfReconnectController.test.ts new file mode 100644 index 000000000..67b071e0b --- /dev/null +++ b/src/renderer/lib/rfReconnectController.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createRfReconnectController } from './rfReconnectController'; + +describe('createRfReconnectController', () => { + let microtasks: (() => void)[]; + + beforeEach(() => { + microtasks = []; + vi.spyOn(console, 'debug').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function flushMicrotasks(): void { + const queued = microtasks.splice(0, microtasks.length); + for (const fn of queued) fn(); + } + + function create() { + return createRfReconnectController({ + logTag: 'test', + scheduleMicrotask: (fn) => { + microtasks.push(fn); + }, + }); + } + + it('idle link lost starts the owner once', () => { + const c = create(); + const first = c.onLinkLost(); + expect(first.shouldStartOwner).toBe(true); + expect(c.isReconnecting).toBe(true); + expect(c.dirty).toBe(false); + + const runs: number[] = []; + c.scheduleOwner(() => runs.push(1)); + flushMicrotasks(); + expect(runs).toEqual([1]); + }); + + it('Neal race: link lost during opening with slow teardown never double-starts owner', () => { + const c = create(); + expect(c.onLinkLost().shouldStartOwner).toBe(true); + + const ownerStarts: number[] = []; + const startOwner = () => { + ownerStarts.push(ownerStarts.length + 1); + const { generation } = c.beginAttempt(ownerStarts.length); + c.beginOpening(); + return generation; + }; + + c.scheduleOwner(startOwner); + flushMicrotasks(); + expect(ownerStarts).toEqual([1]); + expect(c.phase).toBe('opening'); + expect(c.attemptActive).toBe(true); + + // Mid-open drop (tcp-disconnected): must NOT start a second owner. + const mid = c.onLinkLost(); + expect(mid.shouldStartOwner).toBe(false); + expect(c.dirty).toBe(true); + + // Simulate await disconnect completing after attempt finally: + const settled = c.endAttempt({ keepReconnecting: true }); + expect(settled.shouldSchedule).toBe(true); + c.scheduleOwner(startOwner); + // Stale post-await schedule from a second lost handler — must coalesce to dirty, not run. + c.scheduleOwner(startOwner); + flushMicrotasks(); + expect(ownerStarts).toEqual([1, 2]); + }); + + it('double onLinkLost in the same tick still yields one owner start', () => { + const c = create(); + const a = c.onLinkLost(); + const b = c.onLinkLost(); + expect(a.shouldStartOwner).toBe(true); + expect(b.shouldStartOwner).toBe(false); + expect(c.dirty).toBe(true); + + const runs: number[] = []; + c.scheduleOwner(() => { + runs.push(1); + c.beginAttempt(1); + }); + c.scheduleOwner(() => { + runs.push(2); + c.beginAttempt(2); + }); + flushMicrotasks(); + expect(runs).toEqual([1]); + }); + + it('link lost during backoff only dirties; owner schedules after endAttempt', () => { + const c = create(); + c.onLinkLost(); + c.beginAttempt(1); + expect(c.phase).toBe('backoff'); + + expect(c.onLinkLost().shouldStartOwner).toBe(false); + expect(c.dirty).toBe(true); + + const runs: number[] = []; + const settled = c.endAttempt({ keepReconnecting: true }); + expect(settled.shouldSchedule).toBe(true); + c.scheduleOwner(() => runs.push(1)); + flushMicrotasks(); + expect(runs).toEqual([1]); + }); + + it('cancel mid-cycle prevents further owner runs', () => { + const c = create(); + c.onLinkLost(); + c.beginAttempt(1); + c.cancel(); + expect(c.isReconnecting).toBe(false); + expect(c.phase).toBe('idle'); + + const runs: number[] = []; + c.scheduleOwner(() => runs.push(1)); + flushMicrotasks(); + expect(runs).toEqual([]); + }); + + it('markSuccess clears reconnecting and dirty', () => { + const c = create(); + c.onLinkLost(); + c.beginAttempt(1); + c.beginOpening(); + c.markDirty(); + c.markSuccess(); + expect(c.isReconnecting).toBe(false); + expect(c.dirty).toBe(false); + expect(c.attemptActive).toBe(false); + }); + + it('scheduleOwner during attemptActive sets dirty instead of running', () => { + const c = create(); + c.onLinkLost(); + c.beginAttempt(1); + const runs: number[] = []; + c.scheduleOwner(() => runs.push(1)); + flushMicrotasks(); + expect(runs).toEqual([]); + expect(c.dirty).toBe(true); + }); +}); diff --git a/src/renderer/lib/rfReconnectController.ts b/src/renderer/lib/rfReconnectController.ts new file mode 100644 index 000000000..ea8d65070 --- /dev/null +++ b/src/renderer/lib/rfReconnectController.ts @@ -0,0 +1,213 @@ +/** + * Single-owner LoRa RF reconnect scheduler for MeshCore and Meshtastic. + * + * Invariant: while a reconnect cycle is active (`isReconnecting`), `onLinkLost` never + * starts a parallel attempt — it only bumps generation and sets `dirty`. The attempt + * owner (backoff delay abort / attempt finally) is the sole caller of the next schedule. + * + * This closes the n7eal TCP race where connection-lost awaited disconnect, the in-flight + * attempt's finally cleared "in flight" and scheduled attempt N, then lost resumed and + * scheduled attempt N+1 (~30ms later). + */ + +export type RfReconnectPhase = 'idle' | 'backoff' | 'opening'; + +export type RfReconnectDelayResult = 'done' | 'aborted' | 'suspended'; + +export interface RfReconnectControllerOptions { + /** Prefix for console.debug lines (e.g. useMeshcoreRuntime). */ + logTag: string; + /** Optional inject for tests (default queueMicrotask). */ + scheduleMicrotask?: (fn: () => void) => void; +} + +export interface RfReconnectLinkLostResult { + /** True only when this call transitions idle → reconnecting and the caller should kick the owner. */ + shouldStartOwner: boolean; + /** Generation after the bump (in-flight open/backoff should abort when mismatched). */ + generation: number; +} + +export interface RfReconnectController { + readonly phase: RfReconnectPhase; + readonly generation: number; + readonly attempt: number; + readonly isReconnecting: boolean; + readonly dirty: boolean; + /** True from attempt start through backoff+open until settle (not only during open). */ + readonly attemptActive: boolean; + + /** + * Link drop. When already reconnecting: bump generation + dirty, never ask caller to start + * another owner. When idle: mark reconnecting and ask caller to start the owner once. + */ + onLinkLost(): RfReconnectLinkLostResult; + + /** User disconnect / power suspend cancel — idle, clear dirty, bump generation. */ + cancel(): void; + + /** Reset attempt counter after a successful reconnect (cycle ends). */ + markSuccess(): void; + + /** Exhausted max attempts — back to idle. */ + markExhausted(): void; + + /** + * Begin an owner attempt (caller increments UI attempt / starts delay). + * Sets phase=backoff and attemptActive until {@link endAttempt}. + */ + beginAttempt(attemptNumber: number): { generation: number }; + + /** Transition backoff → opening after delay completes. */ + beginOpening(): void; + + /** + * End the current owner attempt. If dirty and still reconnecting, returns shouldSchedule + * so the caller runs exactly one follow-up (Neal flush path). + */ + endAttempt(opts?: { keepReconnecting?: boolean }): { + shouldSchedule: boolean; + generation: number; + }; + + /** + * Coalesced schedule of the owner runner. If an attempt is already active, sets dirty + * instead of invoking run (single-flight during backoff+open). + */ + scheduleOwner(run: () => void): void; + + /** Test/helper: set dirty without a link-lost (e.g. delay-abort deferred restart). */ + markDirty(): void; +} + +export function createRfReconnectController( + options: RfReconnectControllerOptions, +): RfReconnectController { + const logTag = options.logTag; + const scheduleMicrotask = options.scheduleMicrotask ?? queueMicrotask; + + let phase: RfReconnectPhase = 'idle'; + let generation = 0; + let attempt = 0; + let isReconnecting = false; + let dirty = false; + let attemptActive = false; + let schedulePending = false; + + const self: RfReconnectController = { + get phase() { + return phase; + }, + get generation() { + return generation; + }, + get attempt() { + return attempt; + }, + get isReconnecting() { + return isReconnecting; + }, + get dirty() { + return dirty; + }, + get attemptActive() { + return attemptActive; + }, + + onLinkLost(): RfReconnectLinkLostResult { + generation += 1; + if (isReconnecting) { + dirty = true; + console.debug( + `[${logTag}] rfReconnect: link lost during ${phase} — dirty gen=${generation} (owner schedules)`, + ); + return { shouldStartOwner: false, generation }; + } + isReconnecting = true; + dirty = false; + console.debug(`[${logTag}] rfReconnect: link lost — start owner gen=${generation}`); + return { shouldStartOwner: true, generation }; + }, + + cancel(): void { + generation += 1; + isReconnecting = false; + dirty = false; + attemptActive = false; + attempt = 0; + phase = 'idle'; + schedulePending = false; + }, + + markSuccess(): void { + isReconnecting = false; + dirty = false; + attemptActive = false; + attempt = 0; + phase = 'idle'; + }, + + markExhausted(): void { + isReconnecting = false; + dirty = false; + attemptActive = false; + phase = 'idle'; + }, + + beginAttempt(attemptNumber: number): { generation: number } { + attempt = attemptNumber; + attemptActive = true; + phase = 'backoff'; + return { generation }; + }, + + beginOpening(): void { + phase = 'opening'; + }, + + endAttempt(opts?: { keepReconnecting?: boolean }): { + shouldSchedule: boolean; + generation: number; + } { + attemptActive = false; + phase = isReconnecting || opts?.keepReconnecting ? 'idle' : 'idle'; + const keep = opts?.keepReconnecting ?? isReconnecting; + if (!keep) { + dirty = false; + isReconnecting = false; + return { shouldSchedule: false, generation }; + } + if (dirty) { + dirty = false; + console.debug( + `[${logTag}] rfReconnect: attempt settled — scheduling deferred owner gen=${generation}`, + ); + return { shouldSchedule: true, generation }; + } + return { shouldSchedule: false, generation }; + }, + + scheduleOwner(run: () => void): void { + if (schedulePending) return; + schedulePending = true; + scheduleMicrotask(() => { + schedulePending = false; + if (!isReconnecting) return; + if (attemptActive) { + dirty = true; + console.debug( + `[${logTag}] rfReconnect: schedule coalesced into dirty (attempt already active)`, + ); + return; + } + run(); + }); + }, + + markDirty(): void { + dirty = true; + }, + }; + + return self; +} diff --git a/src/renderer/runtime/loraRfReconnectParity.contract.test.ts b/src/renderer/runtime/loraRfReconnectParity.contract.test.ts index 5b5060097..dcdad6b55 100644 --- a/src/renderer/runtime/loraRfReconnectParity.contract.test.ts +++ b/src/renderer/runtime/loraRfReconnectParity.contract.test.ts @@ -1,6 +1,6 @@ /** - * Shared LoRa reconnect parity: MeshCore and Meshtastic must flush deferred reconnect via a - * coalesced attempt scheduler — not nested handleConnectionLost (n7eal TCP dual backoff / #792). + * Shared LoRa reconnect parity: MeshCore and Meshtastic must use createRfReconnectController + * so connection-lost never double-schedules while a cycle is active (n7eal TCP / #792–#796). */ import { describe, expect, it } from 'vitest'; @@ -14,52 +14,44 @@ describe('LoRa RF reconnect parity (MeshCore ↔ Meshtastic)', () => { { label: 'MeshCore', source: MESHCORE, + controllerRef: 'meshcoreRfReconnectRef', attemptName: 'attemptMeshcoreReconnect', scheduleRef: 'scheduleMeshcoreReconnectAttemptRef.current()', lostRef: 'handleMeshcoreConnectionLostRef.current()', - deferredRef: 'meshcoreDeferredReconnectRef.current', + lostName: 'handleMeshcoreConnectionLost', }, { label: 'Meshtastic', source: MESHTASTIC, + controllerRef: 'meshtasticRfReconnectRef', attemptName: 'attemptReconnect', scheduleRef: 'scheduleMeshtasticReconnectAttemptRef.current()', lostRef: 'handleConnectionLostRef.current()', - deferredRef: 'meshtasticDeferredReconnectRef.current', + lostName: 'handleConnectionLost', }, ] as const)( - '$label reconnect finally flushes deferred via schedule, not nested connection-lost', - ({ source, attemptName, scheduleRef, lostRef, deferredRef }) => { + '$label uses createRfReconnectController and lost-handler never schedules when cycle active', + ({ source, controllerRef, attemptName, scheduleRef, lostRef, lostName }) => { + expect(source).toContain('createRfReconnectController'); + expect(source).toContain(controllerRef); + expect(source).toContain('shouldStartOwner'); + + const lostBody = extractUseCallbackBody(source, lostName); + expect(lostBody).toContain('onLinkLost()'); + expect(lostBody).toContain('!linkLost.shouldStartOwner'); + // Must not fall through to schedule after await when shouldStartOwner is false. + expect(lostBody).toMatch( + /if \(!linkLost\.shouldStartOwner\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?schedule/, + ); + const reconnectBody = extractUseCallbackBody(source, attemptName); const finallyBody = reconnectBody.slice(reconnectBody.indexOf('finally {')); - expect(finallyBody).toContain(deferredRef); expect(finallyBody).toContain(scheduleRef); expect(finallyBody).not.toContain(lostRef); + expect(finallyBody).toContain('endAttempt'); }, ); - it('MeshCore defers reconnect during backoff instead of starting a parallel attempt', () => { - const lostBody = extractUseCallbackBody(MESHCORE, 'handleMeshcoreConnectionLost'); - expect(lostBody).toContain('deferForBackoff'); - expect(lostBody).toMatch( - /deferForBackoff[\s\S]*?Connection lost during reconnect backoff — defer until delay settles/, - ); - expect(lostBody).toMatch( - /if \(deferForBackoff\) \{[\s\S]*?return;[\s\S]*?scheduleMeshcoreReconnectAttemptRef/, - ); - }); - - it('Meshtastic defers reconnect during backoff instead of starting a parallel attempt', () => { - const lostBody = extractUseCallbackBody(MESHTASTIC, 'handleConnectionLost'); - expect(lostBody).toContain('deferForBackoff'); - expect(lostBody).toMatch( - /deferForBackoff[\s\S]*?Connection lost during reconnect backoff — defer until delay settles/, - ); - expect(lostBody).toMatch( - /if \(deferForBackoff\) \{[\s\S]*?return;[\s\S]*?scheduleMeshtasticReconnectAttemptRef/, - ); - }); - it('MeshCore TCP defers status=configured until after contacts+channels', () => { expect(MESHCORE).toContain("const deferConfiguredUntilRadioInit = transportType === 'tcp'"); expect(MESHCORE).toMatch( @@ -69,4 +61,9 @@ describe('LoRa RF reconnect parity (MeshCore ↔ Meshtastic)', () => { /if \(deferConfiguredUntilRadioInit\) \{[\s\S]*?status: 'configured'[\s\S]*?triggerRoomAutoLoginRef\.current\(\)/, ); }); + + it('MeshCore TCP device_status disconnect does not double-call connection-lost', () => { + // Runtime owns meshcore.tcp.onDisconnected; side effects must skip TCP. + expect(MESHCORE).toContain('window.electronAPI.meshcore.tcp.onDisconnected'); + }); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index c5fd1db0e..4a4d781ef 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -364,22 +364,21 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { ); }); - it('handleMeshcoreConnectionLost defers during reconnect backoff without starting a parallel attempt', () => { + it('handleMeshcoreConnectionLost defers when cycle already active (single-owner controller)', () => { const lostBody = extractUseCallbackBody(RUNTIME_SOURCE, 'handleMeshcoreConnectionLost'); - expect(lostBody).toContain('deferForBackoff'); + expect(lostBody).toContain('onLinkLost()'); + expect(lostBody).toContain('shouldStartOwner'); expect(lostBody).toMatch( - /deferForBackoff[\s\S]*?Connection lost during reconnect backoff — defer until delay settles/, - ); - expect(lostBody).toMatch( - /if \(deferForBackoff\) \{[\s\S]*?return;[\s\S]*?scheduleMeshcoreReconnectAttemptRef/, + /if \(!linkLost\.shouldStartOwner\) \{[\s\S]*?return;[\s\S]*?scheduleMeshcoreReconnectAttemptRef/, ); + expect(RUNTIME_SOURCE).toContain('createRfReconnectController'); }); - it('coalesces reconnect attempt schedules via scheduleMeshcoreReconnectAttempt', () => { - expect(RUNTIME_SOURCE).toContain('meshcoreReconnectSchedulePendingRef'); + it('coalesces reconnect attempt schedules via scheduleOwner', () => { expect(RUNTIME_SOURCE).toContain('scheduleMeshcoreReconnectAttempt'); + expect(RUNTIME_SOURCE).toContain('meshcoreRfReconnectRef'); const scheduleBody = extractUseCallbackBody(RUNTIME_SOURCE, 'scheduleMeshcoreReconnectAttempt'); - expect(scheduleBody).toContain('meshcoreReconnectSchedulePendingRef.current'); + expect(scheduleBody).toContain('scheduleOwner'); expect(scheduleBody).toContain('attemptMeshcoreReconnectRef.current()'); expect(RUNTIME_SOURCE).toMatch( /useLayoutEffect\(\(\) => \{\s*attemptMeshcoreReconnectRef\.current = attemptMeshcoreReconnect;\s*\}, \[attemptMeshcoreReconnect\]\)/, @@ -426,9 +425,10 @@ describe('meshcoreConnSideEffects disconnected handler (regression)', () => { ); }); - it('skips handleConnectionLost on explicit user disconnect', () => { + it('skips handleConnectionLost on explicit user disconnect or MeshCore TCP', () => { + // TCP reconnect is owned by runtime meshcore.tcp.onDisconnected (avoid dual entry). expect(CONN_EVENTS_SOURCE).toMatch( - /if \(shouldReconnect && !meshcoreExplicitDisconnectRef\.current\)/, + /shouldReconnect &&\s*!meshcoreExplicitDisconnectRef\.current &&[\s\S]*?meshcoreConnectTypeRef\.current !== 'tcp'/, ); }); diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 371bbc0f1..37f0f81c1 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -366,6 +366,7 @@ import { type RepeaterCommandService, } from '../lib/repeaterCommandService'; import { createRepeaterRemoteRpcQueue } from '../lib/repeaterRemoteRpcQueue'; +import { createRfReconnectController } from '../lib/rfReconnectController'; import { rfMaxReconnectAttemptsForTransport } from '../lib/rfReconnectShared'; import { registerMeshcoreSerialDisconnectTarget } from '../lib/serialDisconnectRouter'; import { @@ -563,8 +564,10 @@ export function useMeshcoreRuntime() { const meshcoreReconnectConnectInFlightRef = useRef(false); /** Set when Noble drops during an in-flight connect; reconnect runs after connect() settles. */ const meshcoreDeferredReconnectRef = useRef(false); - /** Coalesce loss-handler + finally schedules so TCP drops mid-init cannot start two backoff loops. */ - const meshcoreReconnectSchedulePendingRef = useRef(false); + /** Single-owner reconnect scheduler (shared MeshCore/Meshtastic invariant). */ + const meshcoreRfReconnectRef = useRef( + createRfReconnectController({ logTag: 'useMeshcoreRuntime' }), + ); const meshcoreConnectionParamsRef = useRef<{ rfType: 'ble' | 'serial' | 'tcp'; httpAddress?: string; @@ -2780,6 +2783,7 @@ export function useMeshcoreRuntime() { const generation = meshcoreReconnectGenerationRef.current; meshcoreReconnectAttemptRef.current += 1; + meshcoreRfReconnectRef.current.beginAttempt(meshcoreReconnectAttemptRef.current); setState((s) => ({ ...s, status: 'reconnecting', @@ -2809,9 +2813,13 @@ export function useMeshcoreRuntime() { !meshcoreExplicitDisconnectRef.current ) { meshcoreDeferredReconnectRef.current = false; + meshcoreRfReconnectRef.current.endAttempt({ keepReconnecting: true }); scheduleMeshcoreReconnectAttemptRef.current(); return; } + meshcoreRfReconnectRef.current.endAttempt({ + keepReconnecting: meshcoreIsReconnectingRef.current, + }); // Another cycle may still own reconnect (generation bumped while isReconnecting stayed // true). Only clear the UI when this cycle was cancelled and nothing else is driving it — // otherwise a raced setup-abort / delay abort left status=reconnecting forever (#792). @@ -2826,6 +2834,7 @@ export function useMeshcoreRuntime() { } if (delayResult === 'suspended') { meshcoreIsReconnectingRef.current = false; + meshcoreRfReconnectRef.current.cancel(); setState((s) => ({ ...s, status: 'disconnected', @@ -2843,9 +2852,13 @@ export function useMeshcoreRuntime() { !meshcoreExplicitDisconnectRef.current ) { meshcoreDeferredReconnectRef.current = false; + meshcoreRfReconnectRef.current.endAttempt({ keepReconnecting: true }); scheduleMeshcoreReconnectAttemptRef.current(); return; } + meshcoreRfReconnectRef.current.endAttempt({ + keepReconnecting: meshcoreIsReconnectingRef.current, + }); if (!meshcoreIsReconnectingRef.current) { setState((s) => ({ ...s, @@ -2856,6 +2869,7 @@ export function useMeshcoreRuntime() { return; } + meshcoreRfReconnectRef.current.beginOpening(); let opened: Awaited> | undefined; const isBleReconnect = params.rfType === 'ble'; if (meshcoreReconnectConnectInFlightRef.current) { @@ -2863,6 +2877,7 @@ export function useMeshcoreRuntime() { '[useMeshcoreRuntime] reconnect: skip overlapping open (connect already in flight)', ); meshcoreDeferredReconnectRef.current = true; + meshcoreRfReconnectRef.current.markDirty(); return; } meshcoreReconnectConnectInFlightRef.current = true; @@ -2930,6 +2945,7 @@ export function useMeshcoreRuntime() { meshcoreReconnectAttemptRef.current = 0; meshcoreIsReconnectingRef.current = false; meshcoreDeferredReconnectRef.current = false; + meshcoreRfReconnectRef.current.markSuccess(); setState((s) => ({ ...s, serialNeedsReselect: false, @@ -2989,6 +3005,12 @@ export function useMeshcoreRuntime() { bleConnectInProgressRef.current = false; } if (meshcoreDeferredReconnectRef.current) { + meshcoreRfReconnectRef.current.markDirty(); + } + const settled = meshcoreRfReconnectRef.current.endAttempt({ + keepReconnecting: meshcoreIsReconnectingRef.current, + }); + if (meshcoreDeferredReconnectRef.current || settled.shouldSchedule) { meshcoreDeferredReconnectRef.current = false; if (meshcoreIsReconnectingRef.current) { console.debug( @@ -3007,15 +3029,13 @@ export function useMeshcoreRuntime() { }, [attemptMeshcoreReconnect]); const scheduleMeshcoreReconnectAttempt = useCallback(() => { - if (meshcoreReconnectSchedulePendingRef.current) return; - meshcoreReconnectSchedulePendingRef.current = true; - queueMicrotask(() => { - meshcoreReconnectSchedulePendingRef.current = false; + meshcoreRfReconnectRef.current.scheduleOwner(() => { if (!meshcoreIsReconnectingRef.current || meshcoreExplicitDisconnectRef.current) { return; } if (meshcoreReconnectConnectInFlightRef.current) { meshcoreDeferredReconnectRef.current = true; + meshcoreRfReconnectRef.current.markDirty(); return; } void attemptMeshcoreReconnectRef.current(); @@ -3055,14 +3075,14 @@ export function useMeshcoreRuntime() { if (!rehydrated) return; meshcoreConnectionParamsRef.current = rehydrated; } + // Single-owner: while a cycle is active, onLinkLost only dirties — never schedules after + // await disconnect (n7eal TCP dual attempt 2+3 / #792–#796). const wasReconnecting = meshcoreIsReconnectingRef.current; - // Backoff delay owns the cycle (inFlight false). Bump generation to abort that delay; do not - // start a parallel attemptMeshcoreReconnect — deferred flush on delay abort / schedule owns it. - const deferForBackoff = wasReconnecting && !meshcoreReconnectConnectInFlightRef.current; - if (deferForBackoff) { + const linkLost = meshcoreRfReconnectRef.current.onLinkLost(); + meshcoreReconnectGenerationRef.current = linkLost.generation; + if (!linkLost.shouldStartOwner) { meshcoreDeferredReconnectRef.current = true; } - meshcoreReconnectGenerationRef.current += 1; meshcoreDeviceConfiguredRef.current = false; // Keep sticky BLE suppress while reconnecting so Meshtastic cannot revive the ghost. prearmMeshcoreBleMacSuppressionFromStorage(resolveLastBlePeripheralId('meshcore') ?? null); @@ -3091,18 +3111,15 @@ export function useMeshcoreRuntime() { ); }); } - // Single-flight: if open+attach is still running, generation bump invalidates it; - // that attempt's failure path (or finally deferred flush) schedules the next cycle. - if (meshcoreReconnectConnectInFlightRef.current) { - console.debug( - '[useMeshcoreRuntime] Connection lost — defer reconnect until in-flight open settles', - ); + // Owner (attempt finally / delay-abort) schedules follow-ups. Lost-handler must not + // schedule when a cycle was already active — even if inFlight cleared during await. + if (!linkLost.shouldStartOwner) { meshcoreDeferredReconnectRef.current = true; - return; - } - if (deferForBackoff) { console.debug( - '[useMeshcoreRuntime] Connection lost during reconnect backoff — defer until delay settles', + meshcoreRfReconnectRef.current.phase === 'opening' || + meshcoreReconnectConnectInFlightRef.current + ? '[useMeshcoreRuntime] Connection lost — defer reconnect until in-flight open settles' + : '[useMeshcoreRuntime] Connection lost during reconnect backoff — defer until delay settles', ); return; } @@ -3131,6 +3148,7 @@ export function useMeshcoreRuntime() { const onPowerSuspend = useCallback(() => { meshcoreReconnectGenerationRef.current += 1; meshcoreIsReconnectingRef.current = false; + meshcoreRfReconnectRef.current.cancel(); }, []); const onPowerResume = useCallback(() => { diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index 04fb68e55..fb20ca136 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -201,22 +201,21 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { ); }); - it('handleConnectionLost defers during reconnect backoff without starting a parallel attempt', () => { + it('handleConnectionLost defers when cycle already active (single-owner controller)', () => { const lostBody = extractUseCallbackBody(SOURCE, 'handleConnectionLost'); - expect(lostBody).toContain('deferForBackoff'); + expect(lostBody).toContain('onLinkLost()'); + expect(lostBody).toContain('shouldStartOwner'); expect(lostBody).toMatch( - /deferForBackoff[\s\S]*?Connection lost during reconnect backoff — defer until delay settles/, - ); - expect(lostBody).toMatch( - /if \(deferForBackoff\) \{[\s\S]*?return;[\s\S]*?scheduleMeshtasticReconnectAttemptRef/, + /if \(!linkLost\.shouldStartOwner\) \{[\s\S]*?return;[\s\S]*?scheduleMeshtasticReconnectAttemptRef/, ); + expect(SOURCE).toContain('createRfReconnectController'); }); - it('coalesces reconnect attempt schedules via scheduleMeshtasticReconnectAttempt', () => { - expect(SOURCE).toContain('meshtasticReconnectSchedulePendingRef'); + it('coalesces reconnect attempt schedules via scheduleOwner', () => { expect(SOURCE).toContain('scheduleMeshtasticReconnectAttempt'); + expect(SOURCE).toContain('meshtasticRfReconnectRef'); const scheduleBody = extractUseCallbackBody(SOURCE, 'scheduleMeshtasticReconnectAttempt'); - expect(scheduleBody).toContain('meshtasticReconnectSchedulePendingRef.current'); + expect(scheduleBody).toContain('scheduleOwner'); expect(scheduleBody).toContain('attemptReconnectRef.current()'); expect(SOURCE).toMatch( /useLayoutEffect\(\(\) => \{\s*scheduleMeshtasticReconnectAttemptRef\.current = scheduleMeshtasticReconnectAttempt;\s*\}, \[scheduleMeshtasticReconnectAttempt\]\)/, diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 32f1b6e81..3e9e0612c 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -187,6 +187,7 @@ import type { MeshtasticRawPacketEntry } from '../lib/rawPacketLogConstants'; import { reactionGlyphFromPicker } from '../lib/reactions'; import { enrichMeshtasticReplyPreviews, resolveMeshtasticWireReplyId } from '../lib/replyPreview'; import { rfConnectionTransportOpts } from '../lib/rfConnectionTypes'; +import { createRfReconnectController } from '../lib/rfReconnectController'; import { rfMaxReconnectAttemptsForTransport } from '../lib/rfReconnectShared'; import { registerMeshtasticSerialDisconnectTarget } from '../lib/serialDisconnectRouter'; import { @@ -398,8 +399,10 @@ export function useMeshtasticRuntime() { const bleConnectInProgressRef = useRef(false); /** Disconnect during connect — run handleConnectionLost after connect settles. */ const meshtasticDeferredReconnectRef = useRef(false); - /** Coalesce loss-handler + finally schedules so TCP drops mid-configure cannot start two backoff loops. */ - const meshtasticReconnectSchedulePendingRef = useRef(false); + /** Single-owner reconnect scheduler (shared MeshCore/Meshtastic invariant). */ + const meshtasticRfReconnectRef = useRef( + createRfReconnectController({ logTag: 'useMeshtasticRuntime' }), + ); /** True while reconnect open+configure owns the session (single-flight; blocks overlapping opens). */ const reconnectConnectInFlightRef = useRef(false); const reconnectGenerationRef = useRef(0); @@ -1992,14 +1995,14 @@ export function useMeshtasticRuntime() { const nobleYieldReconnectNudgeRef = useRef(false); const handleConnectionLost = useCallback(() => { + // Single-owner: while a cycle is active, onLinkLost only dirties — never schedules after + // await disconnect (MeshCore n7eal TCP parity / #792–#796). const wasReconnecting = isReconnectingRef.current; - // Backoff delay owns the cycle (inFlight false). Bump generation to abort that delay; do not - // start a parallel attemptReconnect — deferred flush on delay abort / schedule owns it. - const deferForBackoff = wasReconnecting && !reconnectConnectInFlightRef.current; - if (deferForBackoff) { + const linkLost = meshtasticRfReconnectRef.current.onLinkLost(); + reconnectGenerationRef.current = linkLost.generation; + if (!linkLost.shouldStartOwner) { meshtasticDeferredReconnectRef.current = true; } - reconnectGenerationRef.current += 1; const afterNobleYieldRelease = nobleYieldReconnectNudgeRef.current; nobleYieldReconnectNudgeRef.current = false; if (!wasReconnecting) { @@ -2053,18 +2056,15 @@ export function useMeshtasticRuntime() { } // After disconnect: detach wire effects / loss-watch (restores toDevice; never deletes). cleanupSubscriptions(); - // Single-flight: if open+configure is still running, generation bump invalidates it; - // that attempt's failure path (or finally deferred flush) schedules the next cycle. - if (reconnectConnectInFlightRef.current) { - console.debug( - '[useMeshtasticRuntime] Connection lost — defer reconnect until in-flight open settles', - ); + // Owner (attempt finally / delay-abort) schedules follow-ups. Lost-handler must not + // schedule when a cycle was already active — even if inFlight cleared during await. + if (!linkLost.shouldStartOwner) { meshtasticDeferredReconnectRef.current = true; - return; - } - if (deferForBackoff) { console.debug( - '[useMeshtasticRuntime] Connection lost during reconnect backoff — defer until delay settles', + meshtasticRfReconnectRef.current.phase === 'opening' || + reconnectConnectInFlightRef.current + ? '[useMeshtasticRuntime] Connection lost — defer reconnect until in-flight open settles' + : '[useMeshtasticRuntime] Connection lost during reconnect backoff — defer until delay settles', ); return; } @@ -2156,6 +2156,7 @@ export function useMeshtasticRuntime() { const generation = reconnectGenerationRef.current; reconnectAttemptRef.current++; + meshtasticRfReconnectRef.current.beginAttempt(reconnectAttemptRef.current); setState((s) => ({ ...s, status: 'reconnecting', @@ -2180,9 +2181,13 @@ export function useMeshtasticRuntime() { !meshtasticExplicitDisconnectRef.current ) { meshtasticDeferredReconnectRef.current = false; + meshtasticRfReconnectRef.current.endAttempt({ keepReconnecting: true }); scheduleMeshtasticReconnectAttemptRef.current(); return; } + meshtasticRfReconnectRef.current.endAttempt({ + keepReconnecting: isReconnectingRef.current, + }); // Only clear the UI when this cycle was cancelled and nothing else is driving reconnect. if (!isReconnectingRef.current) { setState((s) => ({ @@ -2195,6 +2200,7 @@ export function useMeshtasticRuntime() { } if (delayResult === 'suspended') { isReconnectingRef.current = false; + meshtasticRfReconnectRef.current.cancel(); setState((s) => ({ ...s, status: 'disconnected', @@ -2211,9 +2217,13 @@ export function useMeshtasticRuntime() { !meshtasticExplicitDisconnectRef.current ) { meshtasticDeferredReconnectRef.current = false; + meshtasticRfReconnectRef.current.endAttempt({ keepReconnecting: true }); scheduleMeshtasticReconnectAttemptRef.current(); return; } + meshtasticRfReconnectRef.current.endAttempt({ + keepReconnecting: isReconnectingRef.current, + }); if (!isReconnectingRef.current) { setState((s) => ({ ...s, @@ -2230,9 +2240,11 @@ export function useMeshtasticRuntime() { '[useMeshtasticRuntime] reconnect: skip overlapping open (connect already in flight)', ); meshtasticDeferredReconnectRef.current = true; + meshtasticRfReconnectRef.current.markDirty(); return; } + meshtasticRfReconnectRef.current.beginOpening(); let opened: Awaited> | undefined; const isBleReconnect = params.type === 'ble'; reconnectConnectInFlightRef.current = true; @@ -2302,6 +2314,7 @@ export function useMeshtasticRuntime() { reconnectAttemptRef.current = 0; isReconnectingRef.current = false; meshtasticDeferredReconnectRef.current = false; + meshtasticRfReconnectRef.current.markSuccess(); setState((s) => ({ ...s, serialNeedsReselect: false, @@ -2357,6 +2370,12 @@ export function useMeshtasticRuntime() { reconnectConnectInFlightRef.current = false; if (isBleReconnect) bleConnectInProgressRef.current = false; if (meshtasticDeferredReconnectRef.current) { + meshtasticRfReconnectRef.current.markDirty(); + } + const settled = meshtasticRfReconnectRef.current.endAttempt({ + keepReconnecting: isReconnectingRef.current, + }); + if (meshtasticDeferredReconnectRef.current || settled.shouldSchedule) { meshtasticDeferredReconnectRef.current = false; if (isReconnectingRef.current) { console.debug( @@ -2474,15 +2493,13 @@ export function useMeshtasticRuntime() { attemptReconnectRef.current = attemptReconnect; const scheduleMeshtasticReconnectAttempt = useCallback(() => { - if (meshtasticReconnectSchedulePendingRef.current) return; - meshtasticReconnectSchedulePendingRef.current = true; - queueMicrotask(() => { - meshtasticReconnectSchedulePendingRef.current = false; + meshtasticRfReconnectRef.current.scheduleOwner(() => { if (!isReconnectingRef.current || meshtasticExplicitDisconnectRef.current) { return; } if (reconnectConnectInFlightRef.current) { meshtasticDeferredReconnectRef.current = true; + meshtasticRfReconnectRef.current.markDirty(); return; } void attemptReconnectRef.current(); From 3e905c09af1af7024998eb10398be9432a86c173 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 5 Aug 2026 08:59:40 -0600 Subject: [PATCH 3/4] feat: stamp test vs release build channel into exports and logs Embed GitHub Actions run metadata at package time so support-bundle manifests and startup logs distinguish CI test builds from official releases without changing package.json semver. --- .github/workflows/build.yaml | 7 + .github/workflows/release.yaml | 7 + docs/ci-cd.md | 21 ++- docs/troubleshooting.md | 2 + scripts/ci-write-build-info-env.mjs | 166 +++++++++++++++++++++++ scripts/ci-write-build-info-env.test.mjs | 131 ++++++++++++++++++ scripts/esbuild-main-build.mjs | 16 ++- src/main/log-service.test.ts | 3 +- src/main/log-service.ts | 3 +- src/main/support-bundle.test.ts | 12 +- src/main/support-bundle.ts | 26 +++- src/shared/buildInfo.test.ts | 127 +++++++++++++++++ src/shared/buildInfo.ts | 127 +++++++++++++++++ 13 files changed, 637 insertions(+), 11 deletions(-) create mode 100644 scripts/ci-write-build-info-env.mjs create mode 100644 scripts/ci-write-build-info-env.test.mjs create mode 100644 src/shared/buildInfo.test.ts create mode 100644 src/shared/buildInfo.ts diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f3527ca53..51f18054d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -132,6 +132,13 @@ jobs: name: READ-ME-FIRST-test-build path: release-warnings + # Embeds buildChannel=test + Actions runUrl into the main bundle (support exports / logs). + - name: Stamp CI build info + env: + MESH_CLIENT_BUILD_CHANNEL: test + MESH_CLIENT_BUILD_WORKFLOW: Build Binaries (no release) + run: node scripts/ci-write-build-info-env.mjs + - name: Build env: # ── macOS code signing (only effective on macOS runners) ──────────── diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 5d45a663b..c63b4d9b1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -208,6 +208,13 @@ jobs: exit 1 fi + # Embeds buildChannel=release + tag + Actions runUrl into the main bundle (support exports / logs). + - name: Stamp CI build info + env: + MESH_CLIENT_BUILD_CHANNEL: release + MESH_CLIENT_BUILD_WORKFLOW: Build/Release Electron App + run: node scripts/ci-write-build-info-env.mjs + - name: Build and Publish env: # electron-publish reads GH_TOKEN; GITHUB_TOKEN alone is not used for uploads diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 1fde63cbc..81fd6b6da 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -99,13 +99,14 @@ Triggered by pushing a version tag (e.g., `v1.2.3`): 1. **`schema-release-compare`** — first job; compares this SHA’s `CURRENT_SCHEMA_VERSION` to the last **published** GitHub Release, writes the Actions step summary, and uploads a schema readme artifact. Job outputs feed installer notices and the draft release body. 2. **`prepare-github-release`** — creates a single draft GitHub release for the tag (prevents parallel electron-builder jobs from creating duplicate drafts and 404 asset uploads), then prepends the schema compare note to the draft body. On `workflow_dispatch`, the tag is resolved in the workflow from `package.json` and passed as `RELEASE_TAG` (not read inside the release API script — avoids CodeQL `js/file-access-to-http`). -3. Builds for all three platforms in parallel (or a filtered subset on `workflow_dispatch`): +3. **Stamp CI build info** — `scripts/ci-write-build-info-env.mjs` writes `MESH_CLIENT_BUILD_INFO` (`buildChannel=release` + tag + Actions `runUrl`) into `$GITHUB_ENV` before `dist:*:publish` so support-bundle `manifest.json` and startup logs identify an official release build (see [Build channel stamp](#build-channel-stamp-test-vs-release)). +4. Builds for all three platforms in parallel (or a filtered subset on `workflow_dispatch`): - `macos-latest` → `pnpm run dist:mac:publish` - `ubuntu-latest` → `pnpm run dist:linux:publish` - `windows-latest` → `pnpm run dist:win:publish` -4. Rebuilds native dependencies (`pnpm run rebuild`) -5. Installs Linux build dependencies (`libudev-dev`, `rpm`) -6. Publishes artifacts to GitHub Releases +5. Rebuilds native dependencies (`pnpm run rebuild`) +6. Installs Linux build dependencies (`libudev-dev`, `rpm`) +7. Publishes artifacts to GitHub Releases Linux packaging smoke (`verify-linux-packaging.mjs`) asserts `.deb` **Description** metadata is ASCII-only. See [Release Process](release-process.md). @@ -310,6 +311,18 @@ CI focuses on lint, typecheck, build, Flatpak metadata validation, and coverage ## Packaging smoke builds (`build.yaml` / `flatpak.yaml` / `release.yaml`) +### Build channel stamp (test vs release) + +**Build Binaries** (`build.yaml`) and **Release** (`release.yaml`) run `scripts/ci-write-build-info-env.mjs` before packaging. That writes a JSON `MESH_CLIENT_BUILD_INFO` blob into `$GITHUB_ENV`, which `scripts/esbuild-main-build.mjs` embeds via esbuild `--define` into the main process. + +| Channel | Workflow | Support-bundle `manifest.json` | +| --------- | ------------------------------ | --------------------------------------------------------- | +| `test` | Build Binaries (no release) | `buildChannel: "test"` + `buildInfo.runUrl` (Actions run) | +| `release` | Build/Release Electron App | `buildChannel: "release"` + `tag` + `buildInfo.runUrl` | +| `local` | unmarked `pnpm run dist` / dev | `buildChannel: "local"` only | + +`appVersion` remains `package.json` semver (unchanged). Use `buildChannel` + `buildInfo.runUrl` when triaging Export for GitHub / Developer zips so a test binary is not mistaken for an official release. Startup logs include a compact fragment (`buildChannel=… run=… runId=… sha=…`). + ### Schema compare vs last official release **Build Binaries**, **Build Flatpak**, and **Release** start with a **`schema-release-compare`** job (`scripts/ci-schema-release-compare.mjs`) that: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 83c8f5a87..fcb15317f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -32,6 +32,8 @@ Open the **Log** panel (right rail), enable **debug** if needed, reproduce the p Before opening a GitHub issue, use **App → Support / Bug reports → Export for GitHub**. This writes one zip with the debug snapshot JSON and application log file(s) — the same artifacts maintainers previously asked for in three separate steps. The snapshot includes **Reticulum** sidecar status, interface diagnostics, and config audit when the stack was running at export time (`reticulum` section in `debug-snapshot.json`; `[ReticulumSidecar]` lines in the log). +Open `manifest.json` first when triaging: `appVersion` is package semver; **`buildChannel`** is `test` (Build Binaries), `release` (official Release workflow), or `local` (unmarked local dist). For CI builds, `buildInfo.runUrl` links to the exact GitHub Actions run — do not assume `appVersion` alone means an official release. + **Do not attach Export for Developer or `mesh-client.db` to public GitHub issues.** The developer bundle includes your SQLite database, which may contain **saved passwords** (MeshCore room/repeater credentials, MQTT settings, etc.). It may also include **Reticulum** rnsd config and sidecar stack state under `reticulum/` — share only via a **private channel** when a maintainer requests **Export for Developer**. Works on macOS, Windows, Linux (.deb / .rpm / AppImage), and Flatpak. Local data paths: diff --git a/scripts/ci-write-build-info-env.mjs b/scripts/ci-write-build-info-env.mjs new file mode 100644 index 000000000..db174231e --- /dev/null +++ b/scripts/ci-write-build-info-env.mjs @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * Write MESH_CLIENT_BUILD_INFO JSON to GITHUB_ENV for packaging jobs. + * + * Required env: + * MESH_CLIENT_BUILD_CHANNEL — `test` | `release` + * + * Optional: + * MESH_CLIENT_BUILD_WORKFLOW — workflow display name + * MESH_CLIENT_BUILD_TAG — release tag (e.g. v5.26.0); default from package.json when channel=release + * + * Uses standard Actions env: GITHUB_ENV, GITHUB_RUN_ID, GITHUB_RUN_NUMBER, + * GITHUB_SHA, GITHUB_SERVER_URL, GITHUB_REPOSITORY. + * + * Pure helpers exported for unit tests. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +/** + * @param {string} sha + * @returns {string} + */ +export function shortSha(sha) { + const trimmed = sha.trim(); + if (trimmed.length === 0) return ''; + return trimmed.slice(0, 7); +} + +/** + * @param {{ + * channel: 'test' | 'release' + * workflow?: string + * runId?: string + * runNumber?: string | number + * sha?: string + * serverUrl?: string + * repository?: string + * tag?: string + * }} opts + */ +export function buildMeshClientBuildInfoPayload(opts) { + const channel = opts.channel; + if (channel !== 'test' && channel !== 'release') { + throw new Error(`MESH_CLIENT_BUILD_CHANNEL must be test|release, got: ${String(channel)}`); + } + + /** @type {Record} */ + const payload = { channel }; + + const workflow = opts.workflow?.trim(); + if (workflow) payload.workflow = workflow; + + const runId = opts.runId?.trim(); + if (runId) payload.runId = runId; + + if (opts.runNumber !== undefined && opts.runNumber !== '') { + const n = typeof opts.runNumber === 'number' ? opts.runNumber : Number(String(opts.runNumber)); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`Invalid GITHUB_RUN_NUMBER: ${String(opts.runNumber)}`); + } + payload.runNumber = Math.floor(n); + } + + const shaFull = opts.sha?.trim() ?? ''; + const sha = shortSha(shaFull); + if (sha) payload.sha = sha; + + const serverUrl = (opts.serverUrl ?? 'https://github.com').replace(/\/$/, ''); + const repository = opts.repository?.trim(); + if (runId && repository) { + payload.runUrl = `${serverUrl}/${repository}/actions/runs/${runId}`; + } + + if (channel === 'release') { + const tag = opts.tag?.trim(); + if (tag) payload.tag = tag; + } + + return payload; +} + +/** + * @param {Record} payload + * @returns {string} + */ +export function formatGithubEnvAssignment(payload) { + const json = JSON.stringify(payload); + // Heredoc form keeps JSON special characters safe on all runners (including Windows). + return `MESH_CLIENT_BUILD_INFO< { + it('truncates to 7 chars', () => { + expect(shortSha('abcdef0123456789')).toBe('abcdef0'); + }); +}); + +describe('buildMeshClientBuildInfoPayload', () => { + it('builds test payload with runUrl', () => { + expect( + buildMeshClientBuildInfoPayload({ + channel: 'test', + workflow: 'Build Binaries (no release)', + runId: '123', + runNumber: '1842', + sha: 'abcdef0123456789', + serverUrl: 'https://github.com', + repository: 'Colorado-Mesh/mesh-client', + }), + ).toEqual({ + channel: 'test', + workflow: 'Build Binaries (no release)', + runId: '123', + runNumber: 1842, + sha: 'abcdef0', + runUrl: 'https://github.com/Colorado-Mesh/mesh-client/actions/runs/123', + }); + }); + + it('includes tag for release', () => { + expect( + buildMeshClientBuildInfoPayload({ + channel: 'release', + tag: 'v5.26.0', + runId: '9', + runNumber: 1, + sha: 'deadbeef', + repository: 'Colorado-Mesh/mesh-client', + }), + ).toMatchObject({ + channel: 'release', + tag: 'v5.26.0', + runUrl: 'https://github.com/Colorado-Mesh/mesh-client/actions/runs/9', + }); + }); + + it('rejects invalid channel', () => { + expect(() => + buildMeshClientBuildInfoPayload({ channel: /** @type {any} */ ('nightly') }), + ).toThrow(/test\|release/); + }); +}); + +describe('formatGithubEnvAssignment', () => { + it('uses heredoc delimiters', () => { + const text = formatGithubEnvAssignment({ channel: 'test', runId: '1' }); + expect(text).toContain('MESH_CLIENT_BUILD_INFO< { + /** @type {string[]} */ + const tempDirs = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('appends heredoc assignment to GITHUB_ENV', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-build-info-')); + tempDirs.push(dir); + const envFile = path.join(dir, 'github.env'); + fs.writeFileSync(envFile, '', 'utf8'); + + const { payload } = writeBuildInfoEnv({ + MESH_CLIENT_BUILD_CHANNEL: 'test', + MESH_CLIENT_BUILD_WORKFLOW: 'Build Binaries (no release)', + GITHUB_ENV: envFile, + GITHUB_RUN_ID: '42', + GITHUB_RUN_NUMBER: '7', + GITHUB_SHA: 'abcdef0123456789', + GITHUB_SERVER_URL: 'https://github.com', + GITHUB_REPOSITORY: 'Colorado-Mesh/mesh-client', + }); + + expect(payload.channel).toBe('test'); + const written = fs.readFileSync(envFile, 'utf8'); + expect(written).toContain('MESH_CLIENT_BUILD_INFO< { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mesh-build-info-pkg-')); + tempDirs.push(dir); + const pkgPath = path.join(dir, 'package.json'); + fs.writeFileSync(pkgPath, JSON.stringify({ version: '1.2.3' }), 'utf8'); + const envFile = path.join(dir, 'github.env'); + fs.writeFileSync(envFile, '', 'utf8'); + + const { payload } = writeBuildInfoEnv( + { + MESH_CLIENT_BUILD_CHANNEL: 'release', + GITHUB_ENV: envFile, + GITHUB_RUN_ID: '1', + GITHUB_RUN_NUMBER: '1', + GITHUB_SHA: 'abc', + GITHUB_REPOSITORY: 'Colorado-Mesh/mesh-client', + }, + { packageJsonPath: pkgPath }, + ); + + expect(payload.tag).toBe('v1.2.3'); + expect(readReleaseTagFromPackageJson(pkgPath)).toBe('v1.2.3'); + }); +}); diff --git a/scripts/esbuild-main-build.mjs b/scripts/esbuild-main-build.mjs index 4475abd4b..fd7eb9fa5 100644 --- a/scripts/esbuild-main-build.mjs +++ b/scripts/esbuild-main-build.mjs @@ -2,8 +2,12 @@ /** * Build the Electron main process with shared external package list. * Usage: node scripts/esbuild-main-build.mjs [--minify] [--metafile=path] + * + * When MESH_CLIENT_BUILD_INFO is set (CI packaging), embeds it via esbuild define + * as __MESH_CLIENT_BUILD_INFO__ for src/shared/buildInfo.ts. */ import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -11,8 +15,13 @@ import { mainEsbuildExternalArgs } from './esbuild-main-externals.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); +const require = createRequire(import.meta.url); +const esbuildBin = require.resolve('esbuild/bin/esbuild'); const extraArgs = process.argv.slice(2); +const buildInfoRaw = process.env.MESH_CLIENT_BUILD_INFO ?? ''; +const defineArg = `--define:__MESH_CLIENT_BUILD_INFO__=${JSON.stringify(buildInfoRaw)}`; + const args = [ 'src/main/index.ts', '--bundle', @@ -20,8 +29,13 @@ const args = [ '--outfile=dist-electron/main/index.js', ...mainEsbuildExternalArgs(), '--format=cjs', + defineArg, ...extraArgs, ]; -const result = spawnSync('esbuild', args, { cwd: projectRoot, stdio: 'inherit', shell: true }); +// shell:false so JSON quotes in --define survive on Windows runners +const result = spawnSync(esbuildBin, args, { + cwd: projectRoot, + stdio: 'inherit', +}); process.exit(result.status ?? 1); diff --git a/src/main/log-service.test.ts b/src/main/log-service.test.ts index 5ac4da713..b8690eede 100644 --- a/src/main/log-service.test.ts +++ b/src/main/log-service.test.ts @@ -215,7 +215,7 @@ describe('stripConsoleStyles (via appendLine + getRecentLines)', () => { }); describe('formatRuntimeLogTag', () => { - it('includes platform, arch, electron, node, and packaged fields', async () => { + it('includes platform, arch, electron, node, packaged, and buildChannel fields', async () => { const { formatRuntimeLogTag } = await import('./log-service'); const tag = formatRuntimeLogTag(); expect(tag).toContain('platform='); @@ -223,5 +223,6 @@ describe('formatRuntimeLogTag', () => { expect(tag).toContain('electron='); expect(tag).toContain('node='); expect(tag).toContain('packaged='); + expect(tag).toContain('buildChannel=local'); }); }); diff --git a/src/main/log-service.ts b/src/main/log-service.ts index 909418af9..742e44f45 100644 --- a/src/main/log-service.ts +++ b/src/main/log-service.ts @@ -5,6 +5,7 @@ import { app } from 'electron'; import fs from 'fs'; import path from 'path'; +import { formatBuildInfoLogFragment } from '../shared/buildInfo'; import { formatLogFileTimestamp } from '../shared/formatLogTimestamp'; import { sanitizeForConsoleEcho, @@ -23,7 +24,7 @@ export function formatRuntimeLogTag(): string { : 'unknown'; const packaged = typeof app !== 'undefined' && typeof app.isPackaged === 'boolean' ? app.isPackaged : false; - return `platform=${process.platform} arch=${process.arch} os=${osRelease()} electron=${process.versions.electron} node=${process.versions.node} app=${appVersion} packaged=${packaged}`; + return `platform=${process.platform} arch=${process.arch} os=${osRelease()} electron=${process.versions.electron} node=${process.versions.node} app=${appVersion} packaged=${packaged} ${formatBuildInfoLogFragment()}`; } /** diff --git a/src/main/support-bundle.test.ts b/src/main/support-bundle.test.ts index 58f77214f..3dc8afc50 100644 --- a/src/main/support-bundle.test.ts +++ b/src/main/support-bundle.test.ts @@ -273,14 +273,22 @@ describe('buildSupportBundleZip', () => { expect(names).toContain('mesh-client.log.1'); }); - it('manifest records github kind', async () => { + it('manifest records github kind and buildChannel', async () => { const dest = path.join(workDir, 'manifest.zip'); await buildSupportBundleZip(dest, 'github', '{"ok":true}'); const buf = await fs.promises.readFile(dest); const zip = await JSZip.loadAsync(buf); const manifestRaw = await zip.file('manifest.json')!.async('string'); - const manifest = JSON.parse(manifestRaw) as { kind: string; appVersion: string }; + const manifest = JSON.parse(manifestRaw) as { + kind: string; + appVersion: string; + buildChannel: string; + }; expect(manifest.kind).toBe('mesh-client-github-report'); expect(manifest.appVersion).toBe('9.9.9-test'); + expect(manifest.buildChannel).toBe('local'); + + const readme = await zip.file('README.txt')!.async('string'); + expect(readme).toContain('Build channel: local'); }); }); diff --git a/src/main/support-bundle.ts b/src/main/support-bundle.ts index 08034a6ac..ba3ff1348 100644 --- a/src/main/support-bundle.ts +++ b/src/main/support-bundle.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import JSZip from 'jszip'; import path from 'path'; +import { buildInfoForManifest, getBuildInfo } from '../shared/buildInfo'; import type { SupportBundleMode } from '../shared/support-bundle.types'; import { exportDatabase } from './database'; import { flushLogBeforeQuit, getLogPath } from './log-service'; @@ -92,6 +93,7 @@ export function validateDebugSnapshotJson(debugSnapshotJson: string): Record { const kind = mode === 'github' ? 'mesh-client-github-report' : 'mesh-client-developer-bundle'; + const stamped = buildInfoForManifest(getBuildInfo()); const manifest: Record = { kind, bundleVersion: 1, @@ -99,12 +101,16 @@ function buildManifest(mode: SupportBundleMode): Record { typeof app !== 'undefined' && typeof app.getVersion === 'function' ? app.getVersion() : 'unknown', + buildChannel: stamped.buildChannel, platform: process.platform, arch: process.arch, packaged: typeof app !== 'undefined' && typeof app.isPackaged === 'boolean' ? app.isPackaged : false, capturedAt: new Date().toISOString(), }; + if (stamped.buildInfo) { + manifest.buildInfo = stamped.buildInfo; + } const flatpakId = process.env.FLATPAK_ID; if (typeof flatpakId === 'string' && flatpakId.length > 0) { manifest.flatpakId = flatpakId; @@ -112,17 +118,31 @@ function buildManifest(mode: SupportBundleMode): Record { return manifest; } +function buildChannelReadmeLine(): string { + const stamped = buildInfoForManifest(getBuildInfo()); + const runUrl = + stamped.buildInfo && typeof stamped.buildInfo.runUrl === 'string' + ? stamped.buildInfo.runUrl + : undefined; + if (runUrl) { + return `Build channel: ${stamped.buildChannel} — CI run: ${runUrl}`; + } + return `Build channel: ${stamped.buildChannel}`; +} + function buildReadme(mode: SupportBundleMode): string { if (mode === 'github') { return `mesh-client support bundle (GitHub report) This zip is safe to attach to public GitHub issues. +${buildChannelReadmeLine()} + Contents: debug-snapshot.json — UI/session state for triage (Meshtastic, MeshCore, Reticulum sidecar) mesh-client.log — Application log (current session) mesh-client.log.1 — Rotated log backup (if present) - manifest.json — App version and platform metadata + manifest.json — App version, buildChannel, and platform metadata README.txt — This file Reticulum sidecar health, interface audit, and identity hashes are in debug-snapshot.json @@ -141,6 +161,8 @@ The database may contain saved passwords (MeshCore room/repeater credentials, MQ settings, and similar secrets). Share this bundle only with maintainers via a private channel (email, Discord DM, etc.) when they request it. +${buildChannelReadmeLine()} + Contents: debug-snapshot.json — UI/session state for triage (includes Reticulum sidecar snapshot) mesh-client.db — SQLite database backup (contains secrets) @@ -148,7 +170,7 @@ Contents: reticulum/mesh_client_stack.json — Sidecar stack state, mnemonic redacted (if present) mesh-client.log — Application log (current session) mesh-client.log.1 — Rotated log backup (if present) - manifest.json — App version and platform metadata + manifest.json — App version, buildChannel, and platform metadata README.txt — This file `; } diff --git a/src/shared/buildInfo.test.ts b/src/shared/buildInfo.test.ts new file mode 100644 index 000000000..864952716 --- /dev/null +++ b/src/shared/buildInfo.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { + buildInfoForManifest, + formatBuildInfoLogFragment, + getBuildInfo, + parseBuildInfo, +} from './buildInfo'; + +describe('parseBuildInfo', () => { + it('returns local for empty / whitespace', () => { + expect(parseBuildInfo('')).toEqual({ buildChannel: 'local' }); + expect(parseBuildInfo(' ')).toEqual({ buildChannel: 'local' }); + }); + + it('returns local for invalid JSON', () => { + expect(parseBuildInfo('not-json')).toEqual({ buildChannel: 'local' }); + expect(parseBuildInfo('[]')).toEqual({ buildChannel: 'local' }); + }); + + it('parses test channel with CI look-up fields', () => { + const info = parseBuildInfo( + JSON.stringify({ + channel: 'test', + workflow: 'Build Binaries (no release)', + runNumber: 1842, + runId: '12345678901', + runUrl: 'https://github.com/Colorado-Mesh/mesh-client/actions/runs/12345678901', + sha: 'a1b2c3d', + }), + ); + expect(info).toEqual({ + buildChannel: 'test', + workflow: 'Build Binaries (no release)', + runNumber: 1842, + runId: '12345678901', + runUrl: 'https://github.com/Colorado-Mesh/mesh-client/actions/runs/12345678901', + sha: 'a1b2c3d', + }); + }); + + it('accepts buildChannel key and string runNumber', () => { + const info = parseBuildInfo( + JSON.stringify({ + buildChannel: 'release', + runNumber: '99', + tag: 'v5.26.0', + }), + ); + expect(info.buildChannel).toBe('release'); + expect(info.runNumber).toBe(99); + expect(info.tag).toBe('v5.26.0'); + }); + + it('falls back to local for unknown channel', () => { + expect(parseBuildInfo(JSON.stringify({ channel: 'nightly' }))).toEqual({ + buildChannel: 'local', + }); + }); +}); + +describe('formatBuildInfoLogFragment', () => { + it('includes channel only for local', () => { + expect(formatBuildInfoLogFragment({ buildChannel: 'local' })).toBe('buildChannel=local'); + }); + + it('includes compact CI fields without runUrl', () => { + const fragment = formatBuildInfoLogFragment({ + buildChannel: 'test', + runNumber: 1842, + runId: '12345678901', + runUrl: 'https://github.com/Colorado-Mesh/mesh-client/actions/runs/12345678901', + sha: 'a1b2c3d', + }); + expect(fragment).toBe('buildChannel=test run=1842 runId=12345678901 sha=a1b2c3d'); + expect(fragment).not.toContain('runUrl'); + }); + + it('includes tag for release builds', () => { + expect( + formatBuildInfoLogFragment({ + buildChannel: 'release', + tag: 'v5.26.0', + runNumber: 10, + runId: '99', + sha: 'deadbee', + }), + ).toBe('buildChannel=release tag=v5.26.0 run=10 runId=99 sha=deadbee'); + }); +}); + +describe('buildInfoForManifest', () => { + it('omits buildInfo when only channel is set', () => { + expect(buildInfoForManifest({ buildChannel: 'local' })).toEqual({ + buildChannel: 'local', + }); + }); + + it('includes buildInfo with look-up fields', () => { + expect( + buildInfoForManifest({ + buildChannel: 'test', + workflow: 'Build Binaries (no release)', + runNumber: 1842, + runId: '12345678901', + runUrl: 'https://github.com/Colorado-Mesh/mesh-client/actions/runs/12345678901', + sha: 'a1b2c3d', + }), + ).toEqual({ + buildChannel: 'test', + buildInfo: { + workflow: 'Build Binaries (no release)', + runNumber: 1842, + runId: '12345678901', + runUrl: 'https://github.com/Colorado-Mesh/mesh-client/actions/runs/12345678901', + sha: 'a1b2c3d', + }, + }); + }); +}); + +describe('getBuildInfo', () => { + it('returns local when compile-time stamp is unset (unit tests)', () => { + expect(getBuildInfo()).toEqual({ buildChannel: 'local' }); + }); +}); diff --git a/src/shared/buildInfo.ts b/src/shared/buildInfo.ts new file mode 100644 index 000000000..36e8fa45c --- /dev/null +++ b/src/shared/buildInfo.ts @@ -0,0 +1,127 @@ +/** + * Compile-time CI build stamp for packaged binaries. + * + * Set at main-process esbuild time via `--define:__MESH_CLIENT_BUILD_INFO__=...` + * from env `MESH_CLIENT_BUILD_INFO` (see scripts/esbuild-main-build.mjs and + * scripts/ci-write-build-info-env.mjs). Empty / unset → local unmarked build. + */ + +export type BuildChannel = 'test' | 'release' | 'local'; + +export interface MeshClientBuildInfo { + buildChannel: BuildChannel; + workflow?: string; + runNumber?: number; + runId?: string; + runUrl?: string; + sha?: string; + tag?: string; +} + +/** Injected by esbuild; absent in unit tests / unmarked local builds. */ +declare const __MESH_CLIENT_BUILD_INFO__: string | undefined; + +const BUILD_CHANNELS = new Set(['test', 'release', 'local']); + +function readCompileTimeRaw(): string { + // typeof on an undeclared binding is safe in JS (returns 'undefined'). + return typeof __MESH_CLIENT_BUILD_INFO__ === 'string' ? __MESH_CLIENT_BUILD_INFO__ : ''; +} + +function normalizeChannel(raw: unknown): BuildChannel { + if (typeof raw === 'string' && BUILD_CHANNELS.has(raw as BuildChannel)) { + return raw as BuildChannel; + } + return 'local'; +} + +function optionalNonEmptyString(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function optionalRunNumber(raw: unknown): number | undefined { + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) { + return Math.floor(raw); + } + if (typeof raw === 'string' && /^\d+$/.test(raw.trim())) { + return Number(raw.trim()); + } + return undefined; +} + +/** + * Parse a MESH_CLIENT_BUILD_INFO JSON string into a normalized build stamp. + * Never throws — invalid JSON or unknown channel falls back to `local`. + */ +export function parseBuildInfo(raw: string): MeshClientBuildInfo { + const trimmed = raw.trim(); + if (!trimmed) { + return { buildChannel: 'local' }; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { buildChannel: 'local' }; + } + const obj = parsed as Record; + const buildChannel = normalizeChannel(obj.channel ?? obj.buildChannel); + const info: MeshClientBuildInfo = { buildChannel }; + const workflow = optionalNonEmptyString(obj.workflow); + if (workflow) info.workflow = workflow; + const runNumber = optionalRunNumber(obj.runNumber); + if (runNumber !== undefined) info.runNumber = runNumber; + const runId = optionalNonEmptyString(obj.runId); + if (runId) info.runId = runId; + const runUrl = optionalNonEmptyString(obj.runUrl); + if (runUrl) info.runUrl = runUrl; + const sha = optionalNonEmptyString(obj.sha); + if (sha) info.sha = sha; + const tag = optionalNonEmptyString(obj.tag); + if (tag) info.tag = tag; + return info; + } catch { + // catch-no-log-ok invalid CI stamp — fall back to local so exports still work + return { buildChannel: 'local' }; + } +} + +/** Build stamp baked into the main-process bundle (or `local` when unmarked). */ +export function getBuildInfo(): MeshClientBuildInfo { + return parseBuildInfo(readCompileTimeRaw()); +} + +/** + * Compact fragment for {@link formatRuntimeLogTag} / startup logs. + * Full `runUrl` stays in support-bundle manifest JSON for triage. + */ +export function formatBuildInfoLogFragment(info: MeshClientBuildInfo = getBuildInfo()): string { + const parts: string[] = [`buildChannel=${info.buildChannel}`]; + if (info.tag) parts.push(`tag=${info.tag}`); + if (info.runNumber !== undefined) parts.push(`run=${info.runNumber}`); + if (info.runId) parts.push(`runId=${info.runId}`); + if (info.sha) parts.push(`sha=${info.sha}`); + return parts.join(' '); +} + +/** + * Fields to merge into support-bundle `manifest.json`. + * Always includes `buildChannel`; adds `buildInfo` when CI look-up fields exist. + */ +export function buildInfoForManifest(info: MeshClientBuildInfo = getBuildInfo()): { + buildChannel: BuildChannel; + buildInfo?: Record; +} { + const buildInfo: Record = {}; + if (info.workflow) buildInfo.workflow = info.workflow; + if (info.runNumber !== undefined) buildInfo.runNumber = info.runNumber; + if (info.runId) buildInfo.runId = info.runId; + if (info.runUrl) buildInfo.runUrl = info.runUrl; + if (info.sha) buildInfo.sha = info.sha; + if (info.tag) buildInfo.tag = info.tag; + if (Object.keys(buildInfo).length === 0) { + return { buildChannel: info.buildChannel }; + } + return { buildChannel: info.buildChannel, buildInfo }; +} From 007df3b213a241cd2bd6699f9023dafb67b40cc7 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Wed, 5 Aug 2026 09:15:47 -0600 Subject: [PATCH 4/4] fix: address review on build-info stdout, RF reconnect lifecycle, CI docs Use stdout.write for CI build-info status, markExhausted + controller re-entry after serial rediscovery, and cancel/guard on disconnect and suspend so scheduled owners cannot restart after abandon. --- docs/ci-cd.md | 8 ++--- scripts/ci-write-build-info-env.mjs | 5 +-- src/main/windows-packaging.contract.test.ts | 27 +++++++++++++--- .../lib/rfReconnectController.test.ts | 30 +++++++++++++++++ .../useMeshcoreRuntime.reconnect.test.ts | 23 +++++++++++++ src/renderer/runtime/useMeshcoreRuntime.ts | 12 ++++++- ...htasticRuntime.reconnect-hardening.test.ts | 32 +++++++++++++++++++ src/renderer/runtime/useMeshtasticRuntime.ts | 17 +++++++++- 8 files changed, 142 insertions(+), 12 deletions(-) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 81fd6b6da..9ace7ea67 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -99,13 +99,13 @@ Triggered by pushing a version tag (e.g., `v1.2.3`): 1. **`schema-release-compare`** — first job; compares this SHA’s `CURRENT_SCHEMA_VERSION` to the last **published** GitHub Release, writes the Actions step summary, and uploads a schema readme artifact. Job outputs feed installer notices and the draft release body. 2. **`prepare-github-release`** — creates a single draft GitHub release for the tag (prevents parallel electron-builder jobs from creating duplicate drafts and 404 asset uploads), then prepends the schema compare note to the draft body. On `workflow_dispatch`, the tag is resolved in the workflow from `package.json` and passed as `RELEASE_TAG` (not read inside the release API script — avoids CodeQL `js/file-access-to-http`). -3. **Stamp CI build info** — `scripts/ci-write-build-info-env.mjs` writes `MESH_CLIENT_BUILD_INFO` (`buildChannel=release` + tag + Actions `runUrl`) into `$GITHUB_ENV` before `dist:*:publish` so support-bundle `manifest.json` and startup logs identify an official release build (see [Build channel stamp](#build-channel-stamp-test-vs-release)). -4. Builds for all three platforms in parallel (or a filtered subset on `workflow_dispatch`): +3. Installs Linux build dependencies (`libudev-dev`, `rpm`, …) on `ubuntu-latest` runners +4. Rebuilds native dependencies (`pnpm run rebuild`) +5. **Stamp CI build info** — `scripts/ci-write-build-info-env.mjs` writes `MESH_CLIENT_BUILD_INFO` (`buildChannel=release` + tag + Actions `runUrl`) into `$GITHUB_ENV` before `dist:*:publish` so support-bundle `manifest.json` and startup logs identify an official release build (see [Build channel stamp](#build-channel-stamp-test-vs-release)). +6. Builds for all three platforms in parallel (or a filtered subset on `workflow_dispatch`): - `macos-latest` → `pnpm run dist:mac:publish` - `ubuntu-latest` → `pnpm run dist:linux:publish` - `windows-latest` → `pnpm run dist:win:publish` -5. Rebuilds native dependencies (`pnpm run rebuild`) -6. Installs Linux build dependencies (`libudev-dev`, `rpm`) 7. Publishes artifacts to GitHub Releases Linux packaging smoke (`verify-linux-packaging.mjs`) asserts `.deb` **Description** metadata is ASCII-only. See [Release Process](release-process.md). diff --git a/scripts/ci-write-build-info-env.mjs b/scripts/ci-write-build-info-env.mjs index db174231e..8c381eb99 100644 --- a/scripts/ci-write-build-info-env.mjs +++ b/scripts/ci-write-build-info-env.mjs @@ -149,10 +149,11 @@ export function writeBuildInfoEnv(env = process.env, opts = {}) { function main() { const { payload } = writeBuildInfoEnv(); - console.log( + process.stdout.write( `Wrote MESH_CLIENT_BUILD_INFO channel=${payload.channel}` + (payload.runId ? ` runId=${payload.runId}` : '') + - (payload.tag ? ` tag=${payload.tag}` : ''), + (payload.tag ? ` tag=${payload.tag}` : '') + + '\n', ); } diff --git a/src/main/windows-packaging.contract.test.ts b/src/main/windows-packaging.contract.test.ts index a494df171..10412fe44 100644 --- a/src/main/windows-packaging.contract.test.ts +++ b/src/main/windows-packaging.contract.test.ts @@ -159,11 +159,30 @@ describe('Windows packaging (contract)', () => { expect(buildWorkflow).not.toContain('win-arm64-install:'); // READ-ME-FIRST must live under release/ in uploads so artifact LCA stays release/ // (paths outside release/ nest as release/release/*.exe and break packaging-smoke). - expect(buildWorkflow).toContain('Stage READ-ME-FIRST into release output'); - expect(buildWorkflow).toContain('release/READ-ME-FIRST-test-build.md'); - expect(buildWorkflow).not.toMatch( - /Upload Windows Artifact[\s\S]*release-warnings\/READ-ME-FIRST-test-build\.md/, + const stageReadmeIdx = buildWorkflow.indexOf('Stage READ-ME-FIRST into release output'); + expect(stageReadmeIdx).toBeGreaterThan(-1); + const firstUploadIdx = Math.min( + ...( + ['Upload macOS Artifact', 'Upload Linux Artifact', 'Upload Windows Artifact'] as const + ).map((name) => { + const idx = buildWorkflow.indexOf(name); + expect(idx, name).toBeGreaterThan(-1); + return idx; + }), ); + expect(stageReadmeIdx).toBeLessThan(firstUploadIdx); + for (const uploadName of [ + 'Upload macOS Artifact', + 'Upload Linux Artifact', + 'Upload Windows Artifact', + ] as const) { + const start = buildWorkflow.indexOf(`- name: ${uploadName}`); + expect(start, uploadName).toBeGreaterThan(-1); + const nextStep = buildWorkflow.indexOf('\n - name:', start + 1); + const block = buildWorkflow.slice(start, nextStep === -1 ? undefined : nextStep); + expect(block).toContain('release/READ-ME-FIRST-test-build.md'); + expect(block).not.toContain('release-warnings/READ-ME-FIRST-test-build.md'); + } const buildJobBlock = buildWorkflow.slice( buildWorkflow.indexOf(' build:'), diff --git a/src/renderer/lib/rfReconnectController.test.ts b/src/renderer/lib/rfReconnectController.test.ts index 67b071e0b..67a163584 100644 --- a/src/renderer/lib/rfReconnectController.test.ts +++ b/src/renderer/lib/rfReconnectController.test.ts @@ -138,6 +138,36 @@ describe('createRfReconnectController', () => { expect(c.attemptActive).toBe(false); }); + it('markExhausted returns to idle so a later onLinkLost can start a new owner', () => { + const c = create(); + c.onLinkLost(); + c.beginAttempt(3); + c.markExhausted(); + expect(c.isReconnecting).toBe(false); + expect(c.phase).toBe('idle'); + expect(c.attemptActive).toBe(false); + + const again = c.onLinkLost(); + expect(again.shouldStartOwner).toBe(true); + expect(c.isReconnecting).toBe(true); + + const runs: number[] = []; + c.scheduleOwner(() => runs.push(1)); + flushMicrotasks(); + expect(runs).toEqual([1]); + }); + + it('cancel before scheduled owner runs prevents the owner', () => { + const c = create(); + c.onLinkLost(); + const runs: number[] = []; + c.scheduleOwner(() => runs.push(1)); + c.cancel(); + flushMicrotasks(); + expect(runs).toEqual([]); + expect(c.isReconnecting).toBe(false); + }); + it('scheduleOwner during attemptActive sets dirty instead of running', () => { const c = create(); c.onLinkLost(); diff --git a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts index 4a4d781ef..1b2968b6f 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.reconnect.test.ts @@ -284,6 +284,7 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { expect(finalizeBody).toContain('meshcoreIsReconnectingRef.current = false'); expect(finalizeBody).toContain('meshcoreReconnectAttemptRef.current = 0'); expect(finalizeBody).toContain('meshcoreReconnectGenerationRef.current += 1'); + expect(finalizeBody).toContain('meshcoreRfReconnectRef.current.cancel()'); expect(finalizeBody).toContain('meshcoreEverConfiguredRef.current = false'); const teardownIndex = finalizeBody.indexOf('teardownMeshcoreConnEventListeners'); const explicitIndex = finalizeBody.indexOf('meshcoreExplicitDisconnectRef.current = true'); @@ -304,6 +305,28 @@ describe('useMeshcoreRuntime manual disconnect must not auto-reconnect', () => { ); }); + it('attemptMeshcoreReconnect marks controller exhausted and re-enters via onLinkLost after serial rediscovery', () => { + const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); + expect(reconnectBody).toContain('markExhausted()'); + expect(reconnectBody).toMatch( + /startSerialRediscovery\(\{[\s\S]*?onFound:[\s\S]*?onLinkLost\(\)[\s\S]*?scheduleMeshcoreReconnectAttemptRef\.current\(\)/, + ); + expect(reconnectBody).not.toMatch( + /startSerialRediscovery\(\{[\s\S]*?onFound:[\s\S]*?void attemptMeshcoreReconnectRef\.current\(\)/, + ); + }); + + it('cancels controller on suspend, manual disconnect, and connect replacement', () => { + const suspendBody = extractUseCallbackBody(RUNTIME_SOURCE, 'onPowerSuspend'); + expect(suspendBody).toContain('meshcoreRfReconnectRef.current.cancel()'); + const finalizeBody = extractUseCallbackBody(RUNTIME_SOURCE, 'finalizeDriverDisconnect'); + expect(finalizeBody).toContain('meshcoreRfReconnectRef.current.cancel()'); + const prepareBody = extractUseCallbackBody(RUNTIME_SOURCE, 'prepareRfConnect'); + expect(prepareBody).toMatch( + /!opts\?\.preserveReconnectState[\s\S]*?meshcoreRfReconnectRef\.current\.cancel\(\)/, + ); + }); + it('attemptMeshcoreReconnect returns when connection params are cleared', () => { const reconnectBody = extractUseCallbackBody(RUNTIME_SOURCE, 'attemptMeshcoreReconnect'); expect(reconnectBody).toMatch( diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 37f0f81c1..6a967b908 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -2544,6 +2544,7 @@ export function useMeshcoreRuntime() { if (!opts?.preserveReconnectState) { meshcoreReconnectAttemptRef.current = 0; meshcoreIsReconnectingRef.current = false; + meshcoreRfReconnectRef.current.cancel(); } }, [teardownMeshcoreConnEventListeners], @@ -2641,6 +2642,7 @@ export function useMeshcoreRuntime() { meshcoreIsReconnectingRef.current = false; meshcoreReconnectAttemptRef.current = 0; meshcoreReconnectGenerationRef.current += 1; + meshcoreRfReconnectRef.current.cancel(); meshcoreSetupGenerationRef.current += 1; const ackEntries = new Set(pendingAcksRef.current.values()); for (const e of ackEntries) { @@ -2739,6 +2741,7 @@ export function useMeshcoreRuntime() { if (meshcoreReconnectAttemptRef.current >= maxReconnectAttempts) { meshcoreIsReconnectingRef.current = false; meshcoreReconnectAttemptRef.current = 0; + meshcoreRfReconnectRef.current.markExhausted(); if (params.rfType === 'ble') { bleConnectInProgressRef.current = false; } @@ -2764,7 +2767,14 @@ export function useMeshcoreRuntime() { })); meshcoreIsReconnectingRef.current = true; meshcoreReconnectAttemptRef.current = 0; - void attemptMeshcoreReconnectRef.current(); + // Re-enter through the controller after markExhausted (idle → owner). + const linkLost = meshcoreRfReconnectRef.current.onLinkLost(); + meshcoreReconnectGenerationRef.current = linkLost.generation; + if (linkLost.shouldStartOwner) { + scheduleMeshcoreReconnectAttemptRef.current(); + } else { + meshcoreDeferredReconnectRef.current = true; + } }, onTimeout: () => { void forgetGrantedSerialPortBestEffort(exhaustedSerialPort); diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index fb20ca136..cc5f65c53 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -211,6 +211,37 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { expect(SOURCE).toContain('createRfReconnectController'); }); + it('handleConnectionLost returns early on explicit user disconnect', () => { + const lostBody = extractUseCallbackBody(SOURCE, 'handleConnectionLost'); + expect(lostBody).toMatch( + /if \(meshtasticExplicitDisconnectRef\.current\) \{[\s\S]*?skip reconnect \(user disconnect\)/, + ); + const explicitIdx = lostBody.indexOf('meshtasticExplicitDisconnectRef.current'); + const onLinkLostIdx = lostBody.indexOf('onLinkLost()'); + expect(explicitIdx).toBeGreaterThanOrEqual(0); + expect(onLinkLostIdx).toBeGreaterThan(explicitIdx); + }); + + it('attemptReconnect marks controller exhausted and re-enters via onLinkLost after serial rediscovery', () => { + const reconnectBody = extractUseCallbackBody(SOURCE, 'attemptReconnect'); + expect(reconnectBody).toContain('markExhausted()'); + expect(reconnectBody).toMatch( + /startSerialRediscovery\(\{[\s\S]*?onFound:[\s\S]*?onLinkLost\(\)[\s\S]*?scheduleMeshtasticReconnectAttemptRef\.current\(\)/, + ); + expect(reconnectBody).not.toMatch( + /startSerialRediscovery\(\{[\s\S]*?onFound:[\s\S]*?void attemptReconnectRef\.current\(\)/, + ); + }); + + it('cancels controller on suspend, manual disconnect, and connect replacement', () => { + const suspendBody = extractUseCallbackBody(SOURCE, 'onPowerSuspend'); + expect(suspendBody).toContain('meshtasticRfReconnectRef.current.cancel()'); + const finalizeBody = extractUseCallbackBody(SOURCE, 'finalizeDriverDisconnect'); + expect(finalizeBody).toContain('meshtasticRfReconnectRef.current.cancel()'); + const prepareBody = extractUseCallbackBody(SOURCE, 'prepareRfConnect'); + expect(prepareBody).toContain('meshtasticRfReconnectRef.current.cancel()'); + }); + it('coalesces reconnect attempt schedules via scheduleOwner', () => { expect(SOURCE).toContain('scheduleMeshtasticReconnectAttempt'); expect(SOURCE).toContain('meshtasticRfReconnectRef'); @@ -289,6 +320,7 @@ describe('useMeshtasticRuntime manual disconnect must not auto-reconnect', () => expect(finalizeBody).toContain('reconnectConnectInFlightRef.current = false'); expect(finalizeBody).toContain('reconnectAttemptRef.current = 0'); expect(finalizeBody).toContain('reconnectGenerationRef.current++'); + expect(finalizeBody).toContain('meshtasticRfReconnectRef.current.cancel()'); const driverIndex = finalizeBody.indexOf('connectionDriver.disconnect'); const explicitIndex = finalizeBody.indexOf('meshtasticExplicitDisconnectRef.current = true'); const cleanupIdx = finalizeBody.lastIndexOf('cleanupSubscriptions()'); diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 3e9e0612c..f34d4b1f4 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -1995,6 +1995,10 @@ export function useMeshtasticRuntime() { const nobleYieldReconnectNudgeRef = useRef(false); const handleConnectionLost = useCallback(() => { + if (meshtasticExplicitDisconnectRef.current) { + console.debug('[useMeshtasticRuntime] skip reconnect (user disconnect)'); + return; + } // Single-owner: while a cycle is active, onLinkLost only dirties — never schedules after // await disconnect (MeshCore n7eal TCP parity / #792–#796). const wasReconnecting = isReconnectingRef.current; @@ -2101,6 +2105,7 @@ export function useMeshtasticRuntime() { if (reconnectAttemptRef.current >= maxReconnectAttempts) { isReconnectingRef.current = false; reconnectAttemptRef.current = 0; + meshtasticRfReconnectRef.current.markExhausted(); cleanupSubscriptions(); stopWatchdog(); stopGpsInterval(); @@ -2130,7 +2135,14 @@ export function useMeshtasticRuntime() { })); isReconnectingRef.current = true; reconnectAttemptRef.current = 0; - void attemptReconnectRef.current(); + // Re-enter through the controller after markExhausted (idle → owner). + const linkLost = meshtasticRfReconnectRef.current.onLinkLost(); + reconnectGenerationRef.current = linkLost.generation; + if (linkLost.shouldStartOwner) { + scheduleMeshtasticReconnectAttemptRef.current(); + } else { + meshtasticDeferredReconnectRef.current = true; + } }, onTimeout: () => { void forgetGrantedSerialPortBestEffort(exhaustedSerialPort); @@ -2398,6 +2410,7 @@ export function useMeshtasticRuntime() { const onPowerSuspend = useCallback(() => { reconnectGenerationRef.current += 1; isReconnectingRef.current = false; + meshtasticRfReconnectRef.current.cancel(); }, []); const onPowerResume = useCallback(() => { @@ -2553,6 +2566,7 @@ export function useMeshtasticRuntime() { // Supersede any in-flight reconnect open so configure-timeout gating does not stick. reconnectConnectInFlightRef.current = false; reconnectGenerationRef.current++; + meshtasticRfReconnectRef.current.cancel(); if (type === 'ble') { bleConnectInProgressRef.current = true; meshtasticDeferredReconnectRef.current = false; @@ -2718,6 +2732,7 @@ export function useMeshtasticRuntime() { reconnectConnectInFlightRef.current = false; reconnectAttemptRef.current = 0; reconnectGenerationRef.current++; + meshtasticRfReconnectRef.current.cancel(); connectionParamsRef.current = null; const device = deviceRef.current;