Backplane
Connecting to your service…
diff --git a/.github/workflows/windows-standard-user.yml b/.github/workflows/windows-standard-user.yml new file mode 100644 index 0000000000..4158964ea7 --- /dev/null +++ b/.github/workflows/windows-standard-user.yml @@ -0,0 +1,75 @@ +name: Windows standard-user startup +on: + pull_request: + paths: + - 'src/server/effects/storeio.js' + - 'desktop/**' + - 'scripts/windows-standard-user-smoke.ps1' + - '.github/workflows/windows-standard-user.yml' + workflow_dispatch: + inputs: + package_url: + description: Exact packaged Windows ZIP (HTTPS); blank runs production-effect tests only + type: string + default: '' + package_sha256: + type: string + default: '' + expected_version: + type: string + default: '0.14.1' + workflow_call: + inputs: + package_url: + type: string + required: true + package_sha256: + type: string + required: true + expected_version: + type: string + required: true +permissions: + contents: read +jobs: + regressions: + strategy: + matrix: + os: [ubuntu-24.04, macos-15] + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: node --test desktop/test/store-link.test.cjs desktop/test/service-startup-error.test.cjs desktop/test/startup-recovery.test.cjs desktop/test/load-recovery.test.cjs desktop/test/windows-service-launch.test.cjs desktop/test/service-recovery.test.cjs + standard-user: + runs-on: windows-2025 + timeout-minutes: 12 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm --prefix desktop ci --ignore-scripts --no-audit --no-fund + - name: Run with a newly created standard user and Developer Mode OFF + shell: pwsh + env: + PACKAGE_URL: ${{ inputs.package_url }} + PACKAGE_SHA256: ${{ inputs.package_sha256 }} + EXPECTED_VERSION: ${{ inputs.expected_version }} + run: ./scripts/windows-standard-user-smoke.ps1 -PackageUrl $env:PACKAGE_URL -PackageSHA256 $env:PACKAGE_SHA256 -ExpectedVersion $env:EXPECTED_VERSION + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-standard-user-evidence + path: | + build/windows-standard-user/result.json + build/windows-standard-user/package.json + build/windows-standard-user/unit-tests.txt + build/windows-standard-user/*.png + if-no-files-found: error + retention-days: 30 diff --git a/desktop/load-recovery.cjs b/desktop/load-recovery.cjs index 9a2f93ed31..e687cf9836 100644 --- a/desktop/load-recovery.cjs +++ b/desktop/load-recovery.cjs @@ -1,6 +1,6 @@ 'use strict'; -function installLoadRecovery(window, {startupPath, startupURL, origin, session, status, reconnect, quitting, setTimer=setTimeout, clearTimer=clearTimeout}) { +function installLoadRecovery(window, {startupPath, startupURL, origin, session, status, reconnect, quitting, setTimer=setTimeout, clearTimer=clearTimeout, maxProbes=30}) { let recovery; const active = run => run && run === recovery && !quitting() && !window.isDestroyed() && window.webContents.getURL() === startupURL; const wait = async run => { @@ -8,10 +8,16 @@ function installLoadRecovery(window, {startupPath, startupURL, origin, session, run.pending = true; try { // Probe the selected service. An outage must not start another hub. + run.probes++; const response = await session.fetch(origin() + '/hello', {cache:'no-store', signal:AbortSignal.timeout(1000)}); if (!active(run)) return; if (response.ok) { recovery = null;await reconnect();return; } - } catch {} finally { run.pending = false; } + run.error = 'Service returned HTTP ' + response.status; + } catch (error) { run.error = error.message; } finally { run.pending = false; } + if (active(run) && run.probes >= maxProbes) { + status('error', 'Backplane lost its service connection.\n' + run.error + '\nChoose Retry to reconnect.'); + recovery = null;return; + } if (active(run)) { run.timer = setTimer(() => { run.timer = null;void wait(run); }, 1000);run.timer?.unref?.(); } }; window.webContents.on('did-finish-load', () => { @@ -19,10 +25,10 @@ function installLoadRecovery(window, {startupPath, startupURL, origin, session, // Start only after the local document really owns the main frame. if (active(recovery) && !recovery.timer) void wait(recovery); }); - window.webContents.on('did-fail-load', (_event, code, _description, url, main) => { + window.webContents.on('did-fail-load', (_event, code, description, url, main) => { if (!main || code === -3 || quitting() || window.isDestroyed()) return; try { if (new URL(url).origin !== origin()) return; } catch { return; } - clearTimer(recovery?.timer);recovery = {pending:false, timer:null}; + clearTimer(recovery?.timer);recovery = {pending:false, timer:null, probes:0, error:description + ' (' + code + ')'}; // Keep the error in main before replacing Chromium's empty error page. status('connecting', 'Reconnecting to your service…'); void window.loadFile(startupPath).catch(() => {}); diff --git a/desktop/main.cjs b/desktop/main.cjs index 4b04740de6..358f168d82 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -43,7 +43,6 @@ const startupPath = path.join(__dirname, 'startup.html'); const startupURL = pathToFileURL(startupPath).href; const launchedAtMs = Date.now(); let window, origin, connection, serviceReadyAtMs, firstVisibleAtMs, connecting; -let connectTries = 0, connectTimer = null; let connectionStatus = {phase:'connecting', message:'Connecting to your service…'}; let desktopBrowser, browserSession; const browserRestore=new Map(),browserRestoreTasks=new Map(),browserRestoreErrors=new Map(); @@ -178,16 +177,11 @@ async function connect(ses) { } // Start independently of the UI; failed private channels retry in main. if(directBrowser)void connectBrowser(ses).catch(()=>{}); - connectTries = 0; } catch (error) { - // Never a button to press: the service is tried again by itself, a - // growing delay up to 5 s, while the page says it is reconnecting. - status('connecting', 'Reconnecting to your service…'); + // Service.start already bounds recovery/readiness. A failed launch + // needs its actual diagnostic and a deliberate retry, not a spawn loop. + status('error', 'Backplane could not connect to its service.\n' + error.message + '\nFix the problem, then choose Retry.'); console.error('backplane: connect failed:', error?.message); - const wait = Math.min(5000, 500 * 2 ** Math.min(connectTries++, 4)); - clearTimeout(connectTimer); - connectTimer = setTimeout(() => { connectTimer = null;if (!quitting && window && !window.isDestroyed()) void connect(ses); }, wait); - connectTimer.unref?.(); } finally { connecting = null; } })(); return connecting; diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 8def4b3e5f..a92fb81bd1 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "backplane-desktop", - "version": "0.14.0", + "version": "0.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backplane-desktop", - "version": "0.14.0", + "version": "0.14.1", "devDependencies": { "electron": "44.9.2", "playwright-core": "1.64.1" diff --git a/desktop/package.json b/desktop/package.json index abb719ec3c..730a5a1f3d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "backplane-desktop", - "version": "0.14.0", + "version": "0.14.1", "private": true, "description": "Electron desktop for the Bend Backplane service", "main": "main.cjs", diff --git a/desktop/service.cjs b/desktop/service.cjs index fd61bfbb49..d9f6bda86b 100644 --- a/desktop/service.cjs +++ b/desktop/service.cjs @@ -199,11 +199,21 @@ class Service { // Like `backplane --headless`: the listener stays on 127.0.0.1 and the // hub's own tailnet handling decides whether the owner's phone reaches it. const args = ['--home', launch.home, '--port', String(port), '--host', '127.0.0.1', '--web', launch.web, '--foreground']; - this.child = spawn(this.binary, args, { stdio: ['ignore', 'ignore', 'ignore'], env: launch.env, ...(launch.cwd ? { cwd: launch.cwd } : {}), windowsHide: true, detached: true }); + this.child = spawn(this.binary, args, { stdio: ['ignore', 'ignore', 'pipe'], env: launch.env, ...(launch.cwd ? { cwd: launch.cwd } : {}), windowsHide: true, detached: true }); this.child.unref(); - let failure; - this.child.once('error', () => { failure = new Error('Cannot start the packaged service'); }); - this.child.once('exit', code => { failure = new Error(`Service exited (${code})`); }); + // Bound diagnostics in memory; keep draining after readiness for resident + // services so their stderr can never block. No persistent credential log. + let failure, stderr = Buffer.alloc(0), starting = true; + const stream = this.child.stderr; + stream?.on('data', chunk => { + if (starting) stderr = Buffer.concat([stderr, Buffer.from(chunk)]).subarray(-8192); + }); + stream?.unref?.(); + const diagnostic = () => stderr.toString('utf8').replace(/\x1b\[[0-9;]*m/g, '').trim(); + this.child.once('error', error => { failure = new Error(`Cannot start the packaged service: ${error.message}`); }); + // close follows the final stderr bytes; exit can precede them. + this.child.once('close', (code, signal) => { failure = new Error(`Service exited (${signal || code})${diagnostic() ? ':\n' + diagnostic() : ''}`); }); + try { while (Date.now() < deadline) { if (failure) throw failure; try { @@ -219,7 +229,8 @@ class Service { } await new Promise(resolve => setTimeout(resolve, 100)); } - throw new Error('Service readiness timed out'); + throw new Error(`Service readiness timed out${diagnostic() ? ':\n' + diagnostic() : ''}`); + } finally { starting = false; stderr = Buffer.alloc(0); } } async token() { const token = (await fs.readFile(path.join(this.home, 'token'), 'utf8')).trim(); diff --git a/desktop/startup.css b/desktop/startup.css index 8342b6b191..7db310f013 100644 --- a/desktop/startup.css +++ b/desktop/startup.css @@ -1 +1,2 @@ :root{color-scheme:dark;background:#111410;color:#d9dde4;font:16px ui-monospace,"SFMono-Regular",Menlo,Consolas,monospace}body{margin:0;min-height:100vh;display:grid;place-items:center}main{padding:32px;max-width:42rem}h1{font-size:20px}p{overflow-wrap:anywhere;color:#a6adba}button{font:inherit;color:inherit;background:#1a1e25;border:1px solid #5fb3ff;padding:8px 20px}button:focus-visible{outline:2px solid #5fb3ff;outline-offset:3px}@media(max-width:480px){main{padding:20px}}:root.light{color-scheme:light;background:#fafaf8;color:#1b1d21}:root.light p{color:#5b6068}:root.light button{background:#e8e7e2;border-color:#1f6fd1}:root.light button:focus-visible{outline-color:#1f6fd1} +#status{white-space:pre-wrap} diff --git a/desktop/startup.html b/desktop/startup.html index 7704eedafe..746ccc51a4 100644 --- a/desktop/startup.html +++ b/desktop/startup.html @@ -2,4 +2,4 @@
Connecting to your service…
Connecting to your service…