Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/desktop-connect-discovery-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ jobs:
- name: Package the target-native desktop app
run: npm run desktop:package

- name: Run native concurrent Windows authority broker regressions
if: matrix.platform == 'win32'
run: node apps/desktop/scripts/windows-authority-batch-regression.mjs

- name: Run packaged Linux main-to-renderer discovery
if: matrix.platform == 'linux'
shell: bash
Expand Down
113 changes: 85 additions & 28 deletions apps/desktop/scripts/packaged-connect-lifecycle.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { lstat, realpath, rm } from 'node:fs/promises';
import { lstat, open, realpath, rm } from 'node:fs/promises';
import { basename, dirname, isAbsolute, relative } from 'node:path';
import { TextDecoder } from 'node:util';
import { fileURLToPath } from 'node:url';
import {
CONNECT_READY_EVENT,
isExactConnectReadyRecord,
} from './packaged-connect-ready.mjs';

export const CONNECT_READY_EVENT = 'desktop.renderer.connect_discovery.ready';
export { CONNECT_READY_EVENT };
export const CHILD_CAPTURE_MAX_BYTES = 64 * 1024;
export const CHILD_DIAGNOSTIC_MAX_RECORDS = 20;

Expand All @@ -24,6 +28,8 @@ const diagnosticEvents = new Set([
'desktop.main_process.uncaught_exception',
CONNECT_READY_EVENT,
'desktop.renderer.connect_discovery.phase',
// #2056 must enumerate this event in its nested diagnostic-record allowlist.
'desktop.renderer.connect_discovery.proof',
'desktop.renderer.connect_discovery.status',
'desktop.renderer.gone',
'desktop.renderer.ready',
Expand Down Expand Up @@ -60,6 +66,16 @@ const diagnosticCategories = new Set([
'type-mismatch',
'unexpected',
]);
const failureMilestones = new Map([
['desktop.app.ready', 'app-ready'],
['desktop.renderer.ready', 'renderer-ready'],
['desktop.renderer.connect_discovery.proof', 'connect-proof'],
[CONNECT_READY_EVENT, 'ready-publication'],
]);
const allowedFailureMilestones = new Set(failureMilestones.values());
const failureMilestoneEvents = new Map(
[...failureMilestones].map(([event, milestone]) => [milestone, event]),
);

export const boundedChildDiagnostics = records => records.flatMap(record => {
if (!record || typeof record !== 'object' || !diagnosticEvents.has(record.event)) return [];
Expand All @@ -81,25 +97,41 @@ export const boundedChildDiagnostics = records => records.flatMap(record => {
}];
}).slice(0, CHILD_DIAGNOSTIC_MAX_RECORDS);

const exactKeys = (record, expected) => {
const actual = Object.keys(record).sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
};
export const isExactReadyRecord = isExactConnectReadyRecord;

export const isExactReadyRecord = (record, { platform, arch, authorityMechanism }) => {
if (!record || typeof record !== 'object' || Array.isArray(record)) return false;
if (!exactKeys(record, [
'authorityMechanism', 'event', 'level', 'rendererSchemaValid',
'selectedArch', 'selectedPlatform', 'timestamp',
])) return false;
return record.event === CONNECT_READY_EVENT
&& record.level === 'info'
&& typeof record.timestamp === 'string'
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(record.timestamp)
&& record.selectedPlatform === platform
&& record.selectedArch === arch
&& record.authorityMechanism === authorityMechanism
&& record.rendererSchemaValid === true;
export const readPackagedConnectFailureMilestone = async evidencePath => {
if (typeof evidencePath !== 'string' || !isAbsolute(evidencePath)) return undefined;
let handle;
try {
handle = await open(evidencePath, 'r');
const before = await handle.stat();
if (!before.isFile() || before.size <= 0 || before.size > CHILD_CAPTURE_MAX_BYTES) return undefined;
const bytes = Buffer.alloc(before.size);
let offset = 0;
while (offset < bytes.byteLength) {
const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
if (!Number.isSafeInteger(result.bytesRead) || result.bytesRead <= 0) return undefined;
offset += result.bytesRead;
}
const after = await handle.stat();
if (after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size) return undefined;
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
if (!text.endsWith('\n')) return undefined;
let lastMilestone;
for (const line of text.slice(0, -1).split('\n')) {
let record;
try { record = JSON.parse(line); } catch { continue; }
if (!record || typeof record !== 'object' || Array.isArray(record)
|| Object.keys(record).length !== 1 || typeof record.event !== 'string') continue;
const milestone = failureMilestones.get(record.event);
if (milestone) lastMilestone = milestone;
}
return lastMilestone;
} catch {
return undefined;
} finally {
await handle?.close().catch(() => undefined);
}
};

const createRecordCapture = ({ sensitiveNeedles, onRecord, onSensitiveOutput }) => {
Expand Down Expand Up @@ -366,11 +398,13 @@ export const runPackagedConnectLifecycle = async ({
terminationTimeoutMs = 10_000,
streamDrainTimeoutMs = 5_000,
requestShutdown = () => undefined,
readFailureMilestone,
}) => {
const records = [];
const first = deferred();
let firstSettled = false;
let invalidReadyObserved = false;
let readyRecordCount = 0;
let child;
const settleFirst = value => {
if (firstSettled) return;
Expand All @@ -383,6 +417,7 @@ export const runPackagedConnectLifecycle = async ({
onRecord: record => {
if (records.length < RECORD_MAX_COUNT) records.push(record);
if (record.event !== CONNECT_READY_EVENT) return;
readyRecordCount += 1;
const valid = isExactReadyRecord(record, { platform, arch, authorityMechanism });
if (!valid) invalidReadyObserved = true;
settleFirst(valid ? { category: 'ready' } : { category: 'ready-validation' });
Expand Down Expand Up @@ -432,11 +467,12 @@ export const runPackagedConnectLifecycle = async ({
});
close = await waitForClose(child, streamDrainTimeoutMs);
streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs);
primary = closeIsClean(close) && streamsDrained
? 'ready-clean-exit'
: terminationSucceeded && close.closed && streamsDrained
? 'ready-forced-exit'
: 'tree-termination';
// These two categories are intentionally fixed protocol values. The
// #2056 PowerShell parser must enumerate child-remained-alive and
// ready-duplicate in its lifecycle allowlist; they are not a broad union.
primary = terminationSucceeded && close.closed && streamsDrained
? 'child-remained-alive'
: 'tree-termination';
}
} else if (primary === 'child-exit') {
primary = 'child-exit-before-ready';
Expand All @@ -454,10 +490,12 @@ export const runPackagedConnectLifecycle = async ({
if (!streamsDrained) streamsDrained = await drainChildStreams(child, streamDrainTimeoutMs);
capture.finish();
const captureResult = capture.result();
if (primary === 'ready-clean-exit' || primary === 'ready-forced-exit') {
if (primary === 'ready-clean-exit') {
if (captureResult.sensitiveOutput || captureResult.capture === 'truncated') {
primary = 'output-rejected';
} else if (invalidReadyObserved) primary = 'ready-validation';
} else if (invalidReadyObserved) {
primary = 'ready-validation';
} else if (readyRecordCount !== 1) primary = 'ready-duplicate';
}
const secondary = [];
if (terminationAttempted && !terminationSucceeded && primary !== 'ready-clean-exit') {
Expand All @@ -470,8 +508,27 @@ export const runPackagedConnectLifecycle = async ({
child.stderr?.destroy();
child.unref?.();
}
const failureDiagnosticsAuthorized = primary !== 'ready-clean-exit'
&& close?.closed
&& streamsDrained
&& (!terminationAttempted || terminationSucceeded);
if (failureDiagnosticsAuthorized && typeof readFailureMilestone === 'function') {
try {
const boundedMilestone = await withTimeout(
Promise.resolve().then(() => readFailureMilestone()),
streamDrainTimeoutMs,
);
const candidate = boundedMilestone.timedOut ? undefined : boundedMilestone.value;
if (allowedFailureMilestones.has(candidate)) {
const event = failureMilestoneEvents.get(candidate);
// Evidence is appended only to the bounded diagnostic input. It never
// increments readyRecordCount and can never authorize READY.
if (event && records.length < RECORD_MAX_COUNT) records.push({ event });
}
} catch { /* Diagnostic attribution cannot replace the primary lifecycle result. */ }
}
return {
ok: primary === 'ready-clean-exit' || primary === 'ready-forced-exit',
ok: primary === 'ready-clean-exit',
category: primary,
capture: captureResult.capture,
records: boundedChildDiagnostics(records),
Expand Down
98 changes: 87 additions & 11 deletions apps/desktop/scripts/packaged-connect-lifecycle.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { lstat, mkdtemp, realpath, rm } from 'node:fs/promises';
import { lstat, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
Expand All @@ -10,6 +10,7 @@ import {
CONNECT_READY_EVENT,
isExactReadyRecord,
preservePrimaryWithCleanup,
readPackagedConnectFailureMilestone,
removeAuthorizedConnectFixture,
runPackagedConnectLifecycle,
} from './packaged-connect-lifecycle.mjs';
Expand Down Expand Up @@ -106,16 +107,16 @@ describe('packaged Connect bounded child lifecycle', () => {
assert.equal(invocations.length, 1);
});

test('forces a ready app with a hung descendant through an exact bounded taskkill invocation', async () => {
test('terminates but rejects a ready app that remains alive past the shutdown bound', async () => {
const { result, invocations } = await run({
onApp: app => app.write(readyRecord()),
onKiller: (killer, app) => {
app.close(null, 'SIGKILL');
killer.close(0, null);
},
});
assert.equal(result.ok, true);
assert.equal(result.category, 'ready-forced-exit');
assert.equal(result.ok, false);
assert.equal(result.category, 'child-remained-alive');
assert.equal(invocations.length, 2);
assert.deepEqual(invocations[1].args, ['/PID', '4242', '/T', '/F']);
assert.equal(invocations[1].options.shell, false);
Expand Down Expand Up @@ -159,20 +160,17 @@ describe('packaged Connect bounded child lifecycle', () => {
assert.equal(result.ok, false);
});

test('accepts a clean post-proof close racing a taskkill no-process result', async () => {
test('rejects a post-bound close racing a taskkill no-process result', async () => {
const { result } = await run({
onApp: app => app.write(readyRecord()),
onKiller: (killer, app) => {
app.close(0, null);
killer.close(128, null);
},
});
assert.deepEqual(result, {
ok: true,
category: 'ready-clean-exit',
capture: 'complete',
records: [{ event: CONNECT_READY_EVENT }],
});
assert.equal(result.ok, false);
assert.equal(result.category, 'tree-termination');
assert.deepEqual(result.secondary, ['tree-termination-failed']);
});

test('rejects malformed, partial, truncated, and extra-field ready records', async () => {
Expand Down Expand Up @@ -208,6 +206,22 @@ describe('packaged Connect bounded child lifecycle', () => {
assert.deepEqual(result.records, [{ event: CONNECT_READY_EVENT }]);
});

test('rejects duplicate exact READY records after a clean close', async () => {
const { result } = await run({
onApp: app => {
app.write(readyRecord());
app.write(readyRecord({ timestamp: '2026-09-01T22:00:01.000Z' }));
queueMicrotask(() => app.close(0, null));
},
});
assert.equal(result.ok, false);
assert.equal(result.category, 'ready-duplicate');
assert.deepEqual(result.records, [
{ event: CONNECT_READY_EVENT },
{ event: CONNECT_READY_EVENT },
]);
});

test('fails after proof when Windows tree termination cannot be proven', async () => {
const { result } = await run({
onApp: app => app.write(readyRecord()),
Expand Down Expand Up @@ -337,6 +351,68 @@ describe('packaged Connect bounded child lifecycle', () => {
});
});

describe('packaged Connect fixed failure milestone attribution', () => {
test('reads only the last allowlisted event-only milestone from bounded evidence', async () => {
const directory = await mkdtemp(join(tmpdir(), 'propr-connect-milestone-'));
const evidence = join(directory, 'application.smoke-evidence.jsonl');
try {
await writeFile(evidence, [
JSON.stringify({ event: 'desktop.app.ready' }),
JSON.stringify({ event: 'desktop.renderer.ready' }),
JSON.stringify({ event: 'desktop.renderer.connect_discovery.proof' }),
JSON.stringify({ event: CONNECT_READY_EVENT, path: privateWindowsPath }),
JSON.stringify({ event: 'untrusted.event' }),
'',
].join('\n'));
assert.equal(await readPackagedConnectFailureMilestone(evidence), 'connect-proof');
} finally {
await rm(directory, { recursive: true, force: true });
}
});

test('reads diagnostics only after failed lifecycle termination and stream drain', async () => {
let diagnosticReads = 0;
const { result } = await run({
onKiller: (killer, app) => {
app.close(null, 'SIGKILL');
killer.close(0, null);
},
readFailureMilestone: async () => {
diagnosticReads += 1;
return 'connect-proof';
},
});
assert.equal(diagnosticReads, 1);
assert.equal(result.category, 'timeout-before-ready');
assert.deepEqual(result.records, [{ event: 'desktop.renderer.connect_discovery.proof' }]);
assert.equal(Object.hasOwn(result, 'lastMilestone'), false);
});

test('does not read evidence after success or unproven tree termination', async () => {
let diagnosticReads = 0;
const readFailureMilestone = async () => {
diagnosticReads += 1;
return privateWindowsPath;
};
const success = await run({
onApp: app => {
app.write(readyRecord());
queueMicrotask(() => app.close(0, null));
},
readFailureMilestone,
});
assert.equal(success.result.ok, true);
const failedTermination = await run({
onKiller: killer => killer.close(1, null),
readFailureMilestone,
});
assert.equal(failedTermination.result.category, 'timeout-before-ready');
assert.equal(diagnosticReads, 0);
assert.equal(Object.hasOwn(failedTermination.result, 'lastMilestone'), false);
assert.doesNotMatch(JSON.stringify(failedTermination.result), /private-user|SENTINEL/u);
});
});

describe('packaged Connect fixture cleanup', () => {
const fixture = '/canonical-temp/propr-desktop-connect-smoke-AbC123';
const stats = { isDirectory: () => true, isSymbolicLink: () => false };
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/scripts/packaged-connect-ready-child.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {
createConnectReadyPublisher,
createConnectReadyRecord,
} from './packaged-connect-ready.mjs';

if (process.platform !== 'win32' || !['x64', 'arm64'].includes(process.arch)) process.exit(2);

const expected = {
platform: process.platform,
arch: process.arch,
authorityMechanism: 'inherited-standard-handle',
};
const result = createConnectReadyPublisher().publish(createConnectReadyRecord(expected), expected);
if (!result.ok) process.exit(3);
if (process.argv[2] === 'remain-alive') setInterval(() => undefined, 1000);
Loading
Loading