Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/windows-standard-user.yml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 10 additions & 4 deletions desktop/load-recovery.cjs
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
'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 => {
if (!active(run) || run.pending) return;
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', () => {
// The failed navigation can finish before the replacement document does.
// 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(() => {});
Expand Down
12 changes: 3 additions & 9 deletions desktop/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 16 additions & 5 deletions desktop/service.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions desktop/startup.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion desktop/startup.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; form-action 'none'">
<title>Backplane</title><link rel="stylesheet" href="startup.css"></head>
<body><main><h1>Backplane</h1><p id="status" role="status">Connecting to your service…</p></main><script src="startup.js"></script></body></html>
<body><main><h1>Backplane</h1><p id="status" role="status">Connecting to your service…</p><button id="retry" hidden>Retry</button></main><script src="startup.js"></script></body></html>
6 changes: 3 additions & 3 deletions desktop/startup.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const status = document.getElementById('status');
// Main reconnects by itself (it retries until the service answers): this
// page only says so, and has nothing to press.
const update = ({message}) => { status.textContent = message; };
const retry = document.getElementById('retry');
const update = ({phase, message}) => { status.textContent = message; retry.hidden = phase !== 'error'; };
retry.addEventListener('click', () => { retry.hidden = true; void window.backplaneDesktop.retry(); });
window.backplaneDesktop.onConnection(update);
// Main outlives this document, including an error and a press on Reload.
const current = window.backplaneDesktop.connectionState();
Expand Down
6 changes: 6 additions & 0 deletions desktop/test/load-recovery.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ test('closing or leaving recovery cancels probing and cannot reopen a window',as
const f=fixture();f.fail();await settle();const probes=f.state.probes;change(f);await f.tick();assert.equal(f.state.probes,probes);assert.equal(f.state.reconnects,0);
}
});
test('a persistent outage ends with a visible diagnostic and no scheduled retry',async()=>{
const f=fixture();f.fail();await settle();
for(let i=0;i<35;i++)await f.tick();
assert.equal(f.state.probes,30);assert.equal(f.state.timers.size,0);
assert.equal(f.state.status.at(-1)[0],'error');assert.match(f.state.status.at(-1)[1],/Retry/);
});
27 changes: 27 additions & 0 deletions desktop/test/service-startup-error.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict';
const {test}=require('node:test'),assert=require('node:assert/strict');
const fs=require('node:fs'),os=require('node:os'),path=require('node:path'),vm=require('node:vm');
const {EventEmitter}=require('node:events'),{PassThrough}=require('node:stream');
function fixture(t,fail){
const home=fs.mkdtempSync(path.join(os.tmpdir(),'bp-launch-failure-'));t.after(()=>fs.rmSync(home,{recursive:true,force:true}));
const child=new EventEmitter();child.stderr=new PassThrough();child.unref=()=>{};
const module={exports:{}};
const ctx={module,Buffer,AbortSignal,Date,setTimeout,process:{...process,platform:'linux'},require:n=>n==='node:child_process'?{
execFile(){},spawn(_binary,_args,opts){assert.equal(opts.stdio[2],'pipe');queueMicrotask(()=>fail(child));return child;}
}:require(n)};
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../service.cjs'),'utf8'),ctx);
const service=new module.exports.Service({home,binary:path.join(home,'missing-service'),web:home});service.discover=async()=>null;
return service;
}
test('a service failure includes its final stderr and exit code',async t=>{
const service=fixture(t,c=>{c.emit('exit',1);c.stderr.write('EPERM: symlink events.jsonl');c.emit('close',1,null)});
await assert.rejects(service.start(),/Service exited \(1\):\nEPERM: symlink events.jsonl/);
});
test('spawn errors retain the native failure',async t=>{
const service=fixture(t,c=>c.emit('error',Error('spawn ENOENT /packaged/service')));
await assert.rejects(service.start(),/Cannot start the packaged service: spawn ENOENT/);
});
test('startup diagnostics retain only a bounded tail',async t=>{
const service=fixture(t,c=>{c.stderr.write('x'.repeat(20000)+'final error');c.emit('close',2,null)});
await assert.rejects(service.start(),e=>e.message.length<8300&&e.message.endsWith('final error'));
});
28 changes: 11 additions & 17 deletions desktop/test/startup-recovery.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,33 @@ const source=name=>fs.readFileSync(path.join(__dirname,'..',name),'utf8');
function fixture(){
const s=source('main.cjs'),events=[],handlers={};
let reject,resolve;
const ctx={connectionStatus:{phase:'connecting',message:'Connecting to your service…'},connecting:null,quitting:false,directBrowser:false,
const ctx={connectionStatus:{phase:'connecting',message:'Connecting to your service…'},connecting:null,quitting:false,directBrowser:false,pendingPairLink:null,savedRemote:null,
service:{start:()=>new Promise((yes,no)=>{resolve=yes;reject=no;}),token:async()=> 'private'},
window:{isDestroyed:()=>false,webContents:{send:(channel,value)=>events.push(value)},loadURL:async()=>{}},
senderIsWindow:e=>e.main===true,startupURL:'file:///app/startup.html',ipcMain:{on:(n,f)=>handlers[n]=f},Date,console:{error(){}},
connectTries:0,connectTimer:null,timers:[],setTimeout:(f,ms)=>{ctx.timers.push({f,ms});return {unref(){}};},clearTimeout:()=>{}};
vm.createContext(ctx);vm.runInContext(s.slice(s.indexOf('function status('),s.indexOf('// one attempt at a time:')),ctx);
vm.createContext(ctx);vm.runInContext(s.slice(s.indexOf('function status('),s.indexOf('// Startup, wake and retry')),ctx);
const ses={fetch:async()=>({ok:true})};
return {ctx,events,handlers,start:()=>ctx.connect(ses),fail:message=>reject(Error(message)),ready:()=>resolve({origin:'http://127.0.0.1:4000'})};
}
function page(main){
const nodes={status:{textContent:'Connecting to your service…'}};
let click;
const nodes={status:{textContent:'Connecting to your service…'},retry:{hidden:true,addEventListener:(name,fn)=>{assert.equal(name,'click');click=fn;}}};
let listener;
vm.runInNewContext(source('startup.js'),{document:{getElementById:id=>nodes[id]},window:{backplaneDesktop:{onConnection:f=>listener=f,connectionState:()=>main.ctx.connectionStatus,retry:()=>main.start()}}});
return{nodes,receive:()=>listener(main.ctx.connectionStatus)};
return{nodes,receive:()=>listener(main.ctx.connectionStatus),click:()=>click()};
}
test('a failed startup reconnects by itself, says so, and has nothing to press',async()=>{
test('a failed startup retains the actual error across reloads and waits for Retry',async()=>{
const f=fixture(),pending=f.start();f.fail('Could not read credentials');await pending;
const first=page(f),reloaded=page(f);
for(const p of [first,reloaded]){assert.equal(p.nodes.status.textContent,'Reconnecting to your service…');assert.equal(p.nodes.retry,undefined);}
assert.equal(f.ctx.connectionStatus.phase,'connecting');
// the next attempt is due by itself, after a delay that grows to at most 5 s
assert.equal(f.ctx.timers.length,1);assert.equal(f.ctx.timers[0].ms,500);
const again=f.ctx.timers.shift();again.f();
assert.notEqual(f.ctx.connecting,null);f.fail('still down');await new Promise(r=>setImmediate(r));
assert.equal(f.ctx.timers.length,1);assert.equal(f.ctx.timers[0].ms,1000);
f.ctx.connectTries=10;f.ctx.timers.shift().f();f.fail('still down');await new Promise(r=>setImmediate(r));
assert.equal(f.ctx.timers[0].ms,5000);
f.ctx.timers.shift().f();f.ready();await new Promise(r=>setImmediate(r));await new Promise(r=>setImmediate(r));
assert.equal(f.ctx.connecting,null);assert.equal(f.ctx.connectTries,0);assert.equal(f.ctx.timers.length,0);
for(const p of [first,reloaded]){assert.match(p.nodes.status.textContent,/Could not read credentials/);assert.equal(p.nodes.retry.hidden,false);}
assert.equal(f.ctx.connectionStatus.phase,'error');assert.equal(f.ctx.timers.length,0);
first.click();assert.notEqual(f.ctx.connecting,null);first.receive();assert.equal(first.nodes.retry.hidden,true);
f.ready();await f.ctx.connecting;assert.equal(f.ctx.connecting,null);assert.equal(f.ctx.timers.length,0);
});
test('a reload during an attempt never starts a second service attempt',async()=>{
const f=fixture(),pending=f.start();page(f);page(f);assert.equal(f.events.length,1);f.fail('Unavailable');await pending;
assert.equal(f.ctx.timers.length,1);assert.equal(page(f).nodes.status.textContent,'Reconnecting to your service…');
assert.equal(f.ctx.timers.length,0);assert.match(page(f).nodes.status.textContent,/Unavailable/);
});
test('connection state is answered only to this window main frame on the local startup document',()=>{
const f=fixture(),read=(main,url)=>{const e={main,senderFrame:{url}};f.handlers['desktop:connection-now'](e);return e.returnValue;};
Expand Down
Loading
Loading