fix: confirm proxy exit before disconnect
APT build checks / build-checks (push) Successful in 36s
APT build checks / build-checks (pull_request) Successful in 38s

This commit is contained in:
2026-08-19 23:12:56 +00:00
parent f65273c7a9
commit 4a10e6051e
5 changed files with 308 additions and 52 deletions
+69 -12
View File
@@ -1,6 +1,6 @@
'use strict'; 'use strict';
const { app, BrowserWindow, ipcMain, safeStorage, shell } = require('electron'); const { app, BrowserWindow, dialog, ipcMain, safeStorage, shell } = require('electron');
const http = require('node:http'); const http = require('node:http');
const path = require('node:path'); const path = require('node:path');
const { pathToFileURL } = require('node:url'); const { pathToFileURL } = require('node:url');
@@ -23,6 +23,8 @@ let bridgeServer = null;
let runtime = null; let runtime = null;
let pairingController = null; let pairingController = null;
let firstRunPairingSecret = null; let firstRunPairingSecret = null;
let quitCleanupInProgress = false;
let allowConfirmedQuit = false;
function getAppDirectory() { function getAppDirectory() {
return app.isPackaged return app.isPackaged
@@ -83,8 +85,8 @@ function registerIpcHandlers() {
sendConnectionState(); sendConnectionState();
return result; return result;
}); });
registerIpc('disconnect', () => { registerIpc('disconnect', async () => {
const state = runtime.disconnect(); const state = await runtime.disconnect();
sendConnectionState(); sendConnectionState();
return state; return state;
}); });
@@ -157,17 +159,72 @@ app.whenReady().then(() => {
startBridgeServer(); startBridgeServer();
}); });
function closeBridgeServer() {
if (!bridgeServer) return Promise.resolve();
const server = bridgeServer;
return new Promise((resolve, reject) => {
const finish = (error) => {
if (!error || error.code === 'ERR_SERVER_NOT_RUNNING') {
if (bridgeServer === server) bridgeServer = null;
resolve();
return;
}
reject(error);
};
try { server.close(finish); } catch (error) { finish(error); }
});
}
async function notifyQuitBlocked(state) {
sendConnectionState();
const activeCount = state && Array.isArray(state.activeProxies) ? state.activeProxies.length : 0;
const activeProxiesRemain = activeCount > 0;
const processDetail = activeProxiesRemain
? `${activeCount} app-owned proxy process${activeCount === 1 ? '' : 'es'} remain active.`
: 'No app-owned proxy process remains active.';
const sessionDetail = state && state.connected
? ' The Alta session is still connected; resolve the process error and try quitting again.'
: ' Resolve the shutdown error and try quitting again.';
await dialog.showMessageBox({
type: 'error',
title: activeProxiesRemain ? 'Alta Camera Proxy is still running' : 'Alta Camera Proxy could not close',
message: activeProxiesRemain
? 'The app could not confirm that every proxy process exited.'
: 'The app could not complete shutdown safely.',
detail: `${processDetail}${sessionDetail}`,
buttons: ['OK'],
noLink: true,
});
}
app.on('before-quit', (event) => { app.on('before-quit', (event) => {
if (runtime) { if (allowConfirmedQuit) return;
const state = runtime.disconnect(); event.preventDefault();
if (state.activeProxies.length > 0) { if (quitCleanupInProgress) return;
event.preventDefault(); quitCleanupInProgress = true;
sendConnectionState();
return; (async () => {
try {
const state = runtime
? await runtime.disconnect()
: { success: true, connected: false, activeProxies: [] };
if (!state.success || state.activeProxies.length > 0) {
await notifyQuitBlocked(state);
return;
}
await closeBridgeServer();
if (runtime) runtime.sessionStore.dispose();
allowConfirmedQuit = true;
app.quit();
} catch {
const state = runtime
? runtime.getConnectionState()
: { connected: false, activeProxies: [] };
await notifyQuitBlocked(state);
} finally {
quitCleanupInProgress = false;
} }
runtime.sessionStore.dispose(); })();
}
if (bridgeServer) bridgeServer.close();
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
+8 -6
View File
@@ -292,21 +292,23 @@ class AppRuntime {
} }
if (!processId || !deviceId) return { success: false, message: 'Proxy process is not owned by this app.' }; if (!processId || !deviceId) return { success: false, message: 'Proxy process is not owned by this app.' };
const result = this.proxyManager.stopProxy(processId); const result = await this.proxyManager.stopProxy(processId);
if (result.success) this.proxyByDevice.delete(deviceId); if (result.success) this.proxyByDevice.delete(deviceId);
return { ...result, deviceId }; return { ...result, deviceId };
} }
disconnect() { async disconnect() {
const tracked = this._reconcileProxies(); const tracked = this._reconcileProxies();
for (const proxy of tracked) { await Promise.all(tracked.map(async (proxy) => {
try { this.proxyManager.stopProxy(proxy.processId); } catch {} try {
} await this.proxyManager.stopProxy(proxy.processId);
} catch {}
}));
const remaining = this._reconcileProxies(); const remaining = this._reconcileProxies();
if (remaining.length > 0) { if (remaining.length > 0) {
return { return {
success: false, success: false,
message: 'Could not stop every active proxy. The Alta session remains connected.', message: 'Could not confirm every active proxy exited. The Alta session remains connected.',
...this.getConnectionState(), ...this.getConnectionState(),
}; };
} }
+88 -21
View File
@@ -10,6 +10,7 @@ const MAX_HOST_LENGTH = 253;
const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const ALTA_SUFFIXES = ['.avasecurity.com', '.avigilon.com']; const ALTA_SUFFIXES = ['.avasecurity.com', '.avigilon.com'];
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
class ProxyLaunchError extends Error { class ProxyLaunchError extends Error {
constructor(message, code) { constructor(message, code) {
@@ -70,7 +71,10 @@ function createProxyManager({
fs = nodeFs, fs = nodeFs,
spawn = nodeSpawn, spawn = nodeSpawn,
platform = process.platform, platform = process.platform,
now = Date.now now = Date.now,
stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS,
setTimeout = global.setTimeout,
clearTimeout = global.clearTimeout
} = {}) { } = {}) {
if (platform !== 'win32') { if (platform !== 'win32') {
throw new ProxyLaunchError('Proxy helper is supported only on the Windows platform.', 'UNSUPPORTED_PLATFORM'); throw new ProxyLaunchError('Proxy helper is supported only on the Windows platform.', 'UNSUPPORTED_PLATFORM');
@@ -78,7 +82,9 @@ function createProxyManager({
if (typeof appDirectory !== 'string' || !nodePath.win32.isAbsolute(appDirectory)) { if (typeof appDirectory !== 'string' || !nodePath.win32.isAbsolute(appDirectory)) {
throw new ProxyLaunchError('Approved application directory must be an absolute path.', 'INVALID_APP_DIRECTORY'); throw new ProxyLaunchError('Approved application directory must be an absolute path.', 'INVALID_APP_DIRECTORY');
} }
if (typeof fs.existsSync !== 'function' || typeof spawn !== 'function' || typeof now !== 'function') { if (typeof fs.existsSync !== 'function' || typeof spawn !== 'function' || typeof now !== 'function' ||
!Number.isSafeInteger(stopTimeoutMs) || stopTimeoutMs <= 0 ||
typeof setTimeout !== 'function' || typeof clearTimeout !== 'function') {
throw new TypeError('Invalid proxy manager dependency.'); throw new TypeError('Invalid proxy manager dependency.');
} }
@@ -90,6 +96,32 @@ function createProxyManager({
if (current && current.child === child) trackedChildren.delete(processId); if (current && current.child === child) trackedChildren.delete(processId);
} }
function hasConfirmedExit(child) {
return (child.exitCode !== null && child.exitCode !== undefined) ||
(child.signalCode !== null && child.signalCode !== undefined);
}
function finishStopAttempt(entry, result) {
const attempt = entry.stopAttempt;
if (!attempt) return;
entry.stopAttempt = null;
entry.stopPromise = null;
clearTimeout(attempt.timer);
attempt.resolve(result);
}
function confirmExit(entry) {
const current = trackedChildren.get(entry.processId);
if (!current || current.child !== entry.child) return;
trackedChildren.delete(entry.processId);
finishStopAttempt(entry, {
success: true,
processId: entry.processId,
deviceId: entry.deviceId,
status: 'stopped'
});
}
function launchProxy(request) { function launchProxy(request) {
if (!request || typeof request !== 'object' || Array.isArray(request)) { if (!request || typeof request !== 'object' || Array.isArray(request)) {
throw new ProxyLaunchError('Proxy launch request is invalid.', 'INVALID_REQUEST'); throw new ProxyLaunchError('Proxy launch request is invalid.', 'INVALID_REQUEST');
@@ -139,19 +171,24 @@ function createProxyManager({
processId: child.pid, processId: child.pid,
deviceId, deviceId,
startedAt: now(), startedAt: now(),
status: 'running' status: 'running',
stopAttempt: null,
stopPromise: null
}; };
trackedChildren.set(entry.processId, entry); trackedChildren.set(entry.processId, entry);
if (typeof child.once === 'function') { if (typeof child.once === 'function') {
child.once('exit', () => removeIfOwned(entry.processId, child)); child.once('exit', () => confirmExit(entry));
child.once('error', () => removeIfOwned(entry.processId, child)); child.once('close', () => confirmExit(entry));
child.once('error', () => {
if (hasConfirmedExit(child)) confirmExit(entry);
});
} }
return { success: true, ...safeMetadata(entry) }; return { success: true, ...safeMetadata(entry) };
} }
function stopProxy(processId) { async function stopProxy(processId) {
if (!Number.isSafeInteger(processId) || processId <= 0) { if (!Number.isSafeInteger(processId) || processId <= 0) {
throw new ProxyLaunchError('Process identifier is invalid.', 'INVALID_PROCESS_ID'); throw new ProxyLaunchError('Process identifier is invalid.', 'INVALID_PROCESS_ID');
} }
@@ -159,28 +196,56 @@ function createProxyManager({
const entry = trackedChildren.get(processId); const entry = trackedChildren.get(processId);
if (!entry) return { success: false, processId, status: 'not-tracked' }; if (!entry) return { success: false, processId, status: 'not-tracked' };
try { if (hasConfirmedExit(entry.child)) {
const requested = entry.child.kill('SIGTERM');
if (!requested) {
removeIfOwned(processId, entry.child);
return {
success: true,
processId,
deviceId: entry.deviceId,
status: 'already-exited'
};
}
removeIfOwned(processId, entry.child); removeIfOwned(processId, entry.child);
return { return {
success: true, success: true,
processId, processId,
deviceId: entry.deviceId, deviceId: entry.deviceId,
status: 'stop-requested' status: 'already-exited'
}; };
}
if (entry.stopPromise) return entry.stopPromise;
entry.status = 'stopping';
entry.stopPromise = new Promise((resolve) => {
const timer = setTimeout(() => {
finishStopAttempt(entry, {
success: false,
processId,
deviceId: entry.deviceId,
status: 'stop-timeout',
message: 'Timed out waiting for the tracked proxy process to exit.'
});
}, stopTimeoutMs);
entry.stopAttempt = { resolve, timer };
});
const stopPromise = entry.stopPromise;
try {
const requested = entry.child.kill('SIGTERM');
if (!requested) {
if (hasConfirmedExit(entry.child)) {
removeIfOwned(processId, entry.child);
finishStopAttempt(entry, {
success: true,
processId,
deviceId: entry.deviceId,
status: 'already-exited'
});
} else {
finishStopAttempt(entry, {
success: false,
processId,
deviceId: entry.deviceId,
status: 'stop-failed',
message: 'Unable to confirm that the tracked proxy process exited.'
});
}
}
} catch (error) { } catch (error) {
const permissionDenied = error && (error.code === 'EPERM' || error.code === 'EACCES'); const permissionDenied = error && (error.code === 'EPERM' || error.code === 'EACCES');
return { finishStopAttempt(entry, {
success: false, success: false,
processId, processId,
deviceId: entry.deviceId, deviceId: entry.deviceId,
@@ -188,8 +253,9 @@ function createProxyManager({
message: permissionDenied message: permissionDenied
? 'Unable to stop the tracked proxy process: permission denied.' ? 'Unable to stop the tracked proxy process: permission denied.'
: 'Unable to stop the tracked proxy process.' : 'Unable to stop the tracked proxy process.'
}; });
} }
return stopPromise;
} }
function listTrackedProxies() { function listTrackedProxies() {
@@ -200,6 +266,7 @@ function createProxyManager({
} }
module.exports = { module.exports = {
DEFAULT_STOP_TIMEOUT_MS,
HELPER_FILENAME, HELPER_FILENAME,
MAX_USERNAME_LENGTH, MAX_USERNAME_LENGTH,
ProxyLaunchError, ProxyLaunchError,
+82 -10
View File
@@ -24,6 +24,8 @@ class FakeChild extends EventEmitter {
this.killResult = killResult; this.killResult = killResult;
this.killCalls = []; this.killCalls = [];
this.killError = null; this.killError = null;
this.exitCode = null;
this.signalCode = null;
} }
kill(signal) { kill(signal) {
@@ -33,7 +35,13 @@ class FakeChild extends EventEmitter {
} }
} }
function createHarness({ helperExists = true, children = [new FakeChild(4101)] } = {}) { function createHarness({
helperExists = true,
children = [new FakeChild(4101)],
stopTimeoutMs = 5_000,
setTimeout = global.setTimeout,
clearTimeout = global.clearTimeout,
} = {}) {
const calls = []; const calls = [];
let childIndex = 0; let childIndex = 0;
const spawn = (...args) => { const spawn = (...args) => {
@@ -47,7 +55,10 @@ function createHarness({ helperExists = true, children = [new FakeChild(4101)] }
fs: fsStub, fs: fsStub,
spawn, spawn,
platform: 'win32', platform: 'win32',
now: () => 1_777_777_777_777 now: () => 1_777_777_777_777,
stopTimeoutMs,
setTimeout,
clearTimeout,
}); });
return { manager, calls, children }; return { manager, calls, children };
} }
@@ -239,32 +250,38 @@ test('tracks only safe process metadata and never exposes usernames', () => {
assert.doesNotMatch(JSON.stringify(tracked), /proxy\.operator/); assert.doesNotMatch(JSON.stringify(tracked), /proxy\.operator/);
}); });
test('stopping one tracked process leaves the other tracked process alive', () => { test('stopping one tracked process waits for confirmed exit and leaves the other alive', async () => {
const first = new FakeChild(4101); const first = new FakeChild(4101);
const second = new FakeChild(4102); const second = new FakeChild(4102);
const { manager } = createHarness({ children: [first, second] }); const { manager } = createHarness({ children: [first, second] });
manager.launchProxy(validRequest()); manager.launchProxy(validRequest());
manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' })); manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' }));
const result = manager.stopProxy(4101); const stopping = manager.stopProxy(4101);
assert.deepEqual(manager.listTrackedProxies().map((item) => [item.processId, item.status]), [
[4101, 'stopping'],
[4102, 'running'],
]);
first.emit('exit', 0, 'SIGTERM');
const result = await stopping;
assert.deepEqual(result, { assert.deepEqual(result, {
success: true, success: true,
processId: 4101, processId: 4101,
deviceId: VALID_DEVICE_ID, deviceId: VALID_DEVICE_ID,
status: 'stop-requested' status: 'stopped'
}); });
assert.deepEqual(first.killCalls, ['SIGTERM']); assert.deepEqual(first.killCalls, ['SIGTERM']);
assert.deepEqual(second.killCalls, []); assert.deepEqual(second.killCalls, []);
assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4102]); assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4102]);
}); });
test('never stops an untracked PID', () => { test('never stops an untracked PID', async () => {
const child = new FakeChild(4101); const child = new FakeChild(4101);
const { manager } = createHarness({ children: [child] }); const { manager } = createHarness({ children: [child] });
manager.launchProxy(validRequest()); manager.launchProxy(validRequest());
assert.deepEqual(manager.stopProxy(9999), { assert.deepEqual(await manager.stopProxy(9999), {
success: false, success: false,
processId: 9999, processId: 9999,
status: 'not-tracked' status: 'not-tracked'
@@ -272,21 +289,22 @@ test('never stops an untracked PID', () => {
assert.deepEqual(child.killCalls, []); assert.deepEqual(child.killCalls, []);
}); });
test('reports already-exited and permission-denied states honestly', () => { test('reports already-exited and permission-denied states honestly', async () => {
const exited = new FakeChild(4101, false); const exited = new FakeChild(4101, false);
exited.exitCode = 0;
const denied = new FakeChild(4102); const denied = new FakeChild(4102);
denied.killError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }); denied.killError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
const { manager } = createHarness({ children: [exited, denied] }); const { manager } = createHarness({ children: [exited, denied] });
manager.launchProxy(validRequest()); manager.launchProxy(validRequest());
manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' })); manager.launchProxy(validRequest({ deviceId: '123e4567-e89b-42d3-a456-426614174001' }));
assert.deepEqual(manager.stopProxy(4101), { assert.deepEqual(await manager.stopProxy(4101), {
success: true, success: true,
processId: 4101, processId: 4101,
deviceId: VALID_DEVICE_ID, deviceId: VALID_DEVICE_ID,
status: 'already-exited' status: 'already-exited'
}); });
assert.deepEqual(manager.stopProxy(4102), { assert.deepEqual(await manager.stopProxy(4102), {
success: false, success: false,
processId: 4102, processId: 4102,
deviceId: '123e4567-e89b-42d3-a456-426614174001', deviceId: '123e4567-e89b-42d3-a456-426614174001',
@@ -296,6 +314,60 @@ test('reports already-exited and permission-denied states honestly', () => {
assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4102]); assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4102]);
}); });
test('kill true is only a request and times out while the child remains tracked as stopping', async () => {
let timeoutCallback;
const child = new FakeChild(4101, true);
const { manager } = createHarness({
children: [child],
stopTimeoutMs: 25,
setTimeout(callback, delay) {
assert.equal(delay, 25);
timeoutCallback = callback;
return 123;
},
clearTimeout() {},
});
manager.launchProxy(validRequest());
const stopping = manager.stopProxy(4101);
assert.deepEqual(manager.listTrackedProxies().map(({ processId, status }) => ({ processId, status })), [
{ processId: 4101, status: 'stopping' },
]);
timeoutCallback();
assert.deepEqual(await stopping, {
success: false,
processId: 4101,
deviceId: VALID_DEVICE_ID,
status: 'stop-timeout',
message: 'Timed out waiting for the tracked proxy process to exit.',
});
assert.equal(manager.listTrackedProxies()[0].status, 'stopping');
});
test('kill false does not claim success without confirmed exit', async () => {
const child = new FakeChild(4101, false);
const { manager } = createHarness({ children: [child] });
manager.launchProxy(validRequest());
assert.deepEqual(await manager.stopProxy(4101), {
success: false,
processId: 4101,
deviceId: VALID_DEVICE_ID,
status: 'stop-failed',
message: 'Unable to confirm that the tracked proxy process exited.',
});
assert.equal(manager.listTrackedProxies()[0].status, 'stopping');
});
test('generic child errors retain tracking unless process exit is confirmed', () => {
const child = new FakeChild(4101);
const { manager } = createHarness({ children: [child] });
manager.launchProxy(validRequest());
child.emit('error', new Error('transient process error'));
assert.deepEqual(manager.listTrackedProxies().map((entry) => entry.processId), [4101]);
});
test('exit events remove only the matching owned child', () => { test('exit events remove only the matching owned child', () => {
const original = new FakeChild(4101); const original = new FakeChild(4101);
const replacement = new FakeChild(4101); const replacement = new FakeChild(4101);
+61 -3
View File
@@ -275,13 +275,13 @@ test('disconnect stops every owned proxy before clearing the Alta session', asyn
}, },
}, },
}); });
const state = runtime.disconnect(); const state = await runtime.disconnect();
assert.deepEqual(stopped, [4101, 4102]); assert.deepEqual(stopped, [4101, 4102]);
assert.equal(state.connected, false); assert.equal(state.connected, false);
assert.deepEqual(state.activeProxies, []); assert.deepEqual(state.activeProxies, []);
}); });
test('disconnect reports failure truthfully and retains session when an owned proxy cannot stop', () => { test('disconnect reports failure truthfully and retains session when an owned proxy cannot stop', async () => {
const sessionStore = createSessionStore(); const sessionStore = createSessionStore();
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie'); sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
const tracked = [{ processId: 4101, deviceId: '550e8400-e29b-41d4-a716-446655440000', status: 'running', startedAt: 1 }]; const tracked = [{ processId: 4101, deviceId: '550e8400-e29b-41d4-a716-446655440000', status: 'running', startedAt: 1 }];
@@ -293,12 +293,52 @@ test('disconnect reports failure truthfully and retains session when an owned pr
stopProxy() { return { success: false, processId: 4101, status: 'permission-denied' }; }, stopProxy() { return { success: false, processId: 4101, status: 'permission-denied' }; },
}, },
}); });
const state = runtime.disconnect(); const state = await runtime.disconnect();
assert.equal(state.success, false); assert.equal(state.success, false);
assert.equal(state.connected, true); assert.equal(state.connected, true);
assert.deepEqual(state.activeProxies.map((entry) => entry.processId), [4101]); assert.deepEqual(state.activeProxies.map((entry) => entry.processId), [4101]);
}); });
test('kill request without exit keeps proxy tracked and session connected until confirmed exit', async () => {
const { EventEmitter } = require('node:events');
const { createProxyManager } = require('../src/proxy-launch');
const sessionStore = createSessionStore();
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
const deviceId = '550e8400-e29b-41d4-a716-446655440000';
const child = new EventEmitter();
child.pid = 4101;
child.exitCode = null;
child.signalCode = null;
child.kill = () => true;
let timeoutCallback;
const proxyManager = createProxyManager({
appDirectory: 'C:\\Program Files\\Alta Proxy Tool',
fs: { existsSync: () => true },
spawn: () => child,
platform: 'win32',
stopTimeoutMs: 10,
setTimeout(callback) { timeoutCallback = callback; return 1; },
clearTimeout() {},
});
proxyManager.launchProxy({ deploymentHost: 'customer.avasecurity.com', username: 'operator@example.com', deviceId });
const runtime = new AppRuntime({ sessionStore, altaClient: {}, proxyManager });
const firstDisconnect = runtime.disconnect();
assert.equal(proxyManager.listTrackedProxies()[0].status, 'stopping');
timeoutCallback();
const failed = await firstDisconnect;
assert.equal(failed.success, false);
assert.equal(failed.connected, true);
assert.deepEqual(failed.activeProxies, [{ deviceId, processId: 4101 }]);
child.exitCode = 0;
child.emit('exit', 0, null);
const disconnected = await runtime.disconnect();
assert.equal(disconnected.success, true);
assert.equal(disconnected.connected, false);
assert.deepEqual(disconnected.activeProxies, []);
});
test('update runtime is check-only and opens only the fixed GitPeji releases page', async () => { test('update runtime is check-only and opens only the fixed GitPeji releases page', async () => {
const opened = []; const opened = [];
const runtime = new AppRuntime({ const runtime = new AppRuntime({
@@ -340,6 +380,24 @@ test('preload and renderer expose only narrow, credential-free contracts', () =>
assert.match(html, /Bridge Pairing/); assert.match(html, /Bridge Pairing/);
}); });
test('Electron quit waits for confirmed cleanup and reports a safe failure instead of disposing', () => {
const main = read('main.js');
const beforeQuit = main.slice(main.indexOf("app.on('before-quit'"), main.indexOf("app.on('window-all-closed'"));
const preventIndex = beforeQuit.indexOf('event.preventDefault()');
const disconnectIndex = beforeQuit.indexOf('await runtime.disconnect()');
const activeCheckIndex = beforeQuit.indexOf('state.activeProxies.length > 0');
const disposeIndex = beforeQuit.indexOf('runtime.sessionStore.dispose()');
const confirmedQuitIndex = beforeQuit.indexOf('allowConfirmedQuit = true');
const quitIndex = beforeQuit.indexOf('app.quit()');
assert.ok(preventIndex >= 0 && preventIndex < disconnectIndex);
assert.ok(disconnectIndex < activeCheckIndex && activeCheckIndex < disposeIndex);
assert.ok(disposeIndex < confirmedQuitIndex && confirmedQuitIndex < quitIndex);
assert.match(main, /dialog\.showMessageBox/);
assert.match(main, /could not confirm that every proxy process exited/i);
assert.doesNotMatch(beforeQuit, /taskkill|pkill|wmic/i);
});
test('source policy removes legacy credential IPC, shell launch, broad kill, and executable updater', () => { test('source policy removes legacy credential IPC, shell launch, broad kill, and executable updater', () => {
const production = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/electron-runtime.js'] const production = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/electron-runtime.js']
.map(read).join('\n'); .map(read).join('\n');