Skip to content

Commit 7e54606

Browse files
committed
test(lifecycle): verify interruption recovery
1 parent a52e80f commit 7e54606

6 files changed

Lines changed: 262 additions & 3 deletions

File tree

docs/issues/tb-portreeve-desktop-lifecycle-service/issues.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ copyable renderer-safe failures.
6161

6262
## I-5 - Verify packaging and both JavaScript runtimes
6363

64-
- **Status:** in-review
64+
- **Status:** closed
6565
- **Estimate:** 1d
6666
- **Plan steps:** P6
6767
- **Rubric criteria:** R1, R3, R8
@@ -74,7 +74,7 @@ packaged smoke behavior.
7474

7575
## I-6 - Complete native lifecycle and interruption verification
7676

77-
- **Status:** open
77+
- **Status:** in-progress
7878
- **Estimate:** 1.5d
7979
- **Plan steps:** P7
8080
- **Rubric criteria:** R1, R2, R3, R4, R5, R6, R7, R8

docs/issues/tb-portreeve-desktop-lifecycle-service/scratchpad.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,21 @@ Inspect only minified bundle strings - rejected because string presence cannot
113113
prove which source modules entered the bundle. Exercise lifecycle mutations from
114114
the package smoke - rejected because packaging verification must be safe on a
115115
developer's ordinary machine; isolated native mutation coverage belongs to P7.
116+
117+
## [8] Separate interruption recovery from native host mutation evidence
118+
119+
[ ] **Promote**
120+
121+
**Confidence:** HIGH
122+
123+
**Blast Radius:** Final lifecycle verification, cross-process fixtures, Desktop close integration tests, and release evidence
124+
125+
Verify caller death at the shared lifecycle-service boundary with a real second process holding the Unix listener lease: a contender must receive lifecycle_busy while status and purge preview remain available, then SIGKILL the holder and prove a new service instance recovers through fresh status evidence and completes a mutation. Separately bind the real Desktop coordinator to both window and application close guards during a pending direct-controller mutation. Use the existing isolated native release matrix for real launchd and systemd-user mutations. Together these tests cover distinct authorities without adding a dangerous packaged-app mutation backdoor.
126+
127+
**Triggered by:** P7 requires force-interruption recovery, live Desktop close protection, and real launchd/systemd-user evidence without pretending a normal close can prevent SIGKILL
128+
129+
**Alternatives considered:**
130+
Treat the existing raw lock test as sufficient - rejected because it does not
131+
exercise canonical busy results, concurrent reads, or service-owned recovery.
132+
Add a mutation-capable packaged Desktop test hook - rejected because it would
133+
ship unnecessary privileged mutation authority solely for verification.

docs/issues/tb-portreeve-desktop-lifecycle-service/tracker.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
### PR #41 - Desktop packaging and runtime parity
6161

6262
- **Scope:** I-5; P6; advances R1, R3, and R8.
63-
- **Status:** In review; feature-level rubric criteria remain `NOT YET` until
63+
- **Status:** Merged; feature-level rubric criteria remain `NOT YET` until
6464
final native lifecycle and interruption verification completes.
6565
- **Evidence:** [PR 41 boundary packet](pr-41/boundary.json)
6666
- **Decision:** Fail packaging on controller/artifact drift, inspect the final

test/desktop/window-refresh.test.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
bindWindowCloseGuard,
88
bindWindowRefresh,
99
} from '../../apps/desktop/main/window.js';
10+
import { createStateCoordinator } from '../../apps/desktop/main/coordinator.js';
11+
import { lifecycleSnapshot, provisionalArtifact, timestamp } from './fixtures.js';
1012

1113
test('refreshes on focus and pauses polling while hidden or minimized', async () => {
1214
const events = new EventEmitter();
@@ -129,3 +131,80 @@ test('blocks application quit while a lifecycle mutation is active', () => {
129131
unbind();
130132
expect(events.listenerCount('before-quit')).toBe(0);
131133
});
134+
135+
test('binds both close guards to a real pending coordinator lifecycle mutation', async () => {
136+
/** @type {(value?: void) => void} */
137+
let releaseStart = () => {};
138+
const startGate = new Promise((resolvePromise) => {
139+
releaseStart = resolvePromise;
140+
});
141+
const coordinator = createStateCoordinator({
142+
artifact: provisionalArtifact(),
143+
lifecycle: {
144+
clearPurgePreview() {},
145+
async start() {
146+
await startGate;
147+
const status = lifecycleSnapshot();
148+
return {
149+
operation: 'start',
150+
outcome: 'succeeded',
151+
changed: true,
152+
startedAt: timestamp,
153+
completedAt: timestamp,
154+
before: status,
155+
after: status,
156+
error: null,
157+
};
158+
},
159+
status: async () => lifecycleSnapshot(),
160+
},
161+
inventory: { listPorts: async () => [] },
162+
now: () => new Date(timestamp),
163+
});
164+
const windowEvents = new EventEmitter();
165+
const applicationEvents = new EventEmitter();
166+
/** @type {unknown[]} */
167+
const blocked = [];
168+
bindWindowCloseGuard(/** @type {any} */ (windowEvents), coordinator, (state) =>
169+
blocked.push(state),
170+
);
171+
const unbindApplication = bindApplicationCloseGuard(
172+
/** @type {any} */ (applicationEvents),
173+
coordinator,
174+
(state) => blocked.push(state),
175+
);
176+
let prevented = 0;
177+
const event = {
178+
preventDefault() {
179+
prevented += 1;
180+
},
181+
};
182+
183+
const mutation = coordinator.startService();
184+
await Bun.sleep(0);
185+
windowEvents.emit('close', event);
186+
applicationEvents.emit('before-quit', event);
187+
expect(prevented).toBe(2);
188+
expect(blocked).toHaveLength(2);
189+
expect(blocked).toEqual([
190+
{
191+
schemaVersion: 1,
192+
allowed: false,
193+
lifecycle: { operation: 'start', startedAt: timestamp },
194+
attached: [],
195+
},
196+
{
197+
schemaVersion: 1,
198+
allowed: false,
199+
lifecycle: { operation: 'start', startedAt: timestamp },
200+
attached: [],
201+
},
202+
]);
203+
204+
releaseStart();
205+
await mutation;
206+
windowEvents.emit('close', event);
207+
applicationEvents.emit('before-quit', event);
208+
expect(prevented).toBe(2);
209+
unbindApplication();
210+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// @ts-check
2+
3+
import { createLifecycleService } from '../../src/supervision/service.js';
4+
import { lifecycleSnapshot } from '../desktop/fixtures.js';
5+
6+
const lockPath = process.argv[2];
7+
if (lockPath === undefined) throw new Error('Lifecycle lock path is required.');
8+
9+
const service = createLifecycleService({
10+
manager: /** @type {any} */ ({
11+
paths: { lifecycleLockPath: lockPath },
12+
status: async () => lifecycleSnapshot(),
13+
async restart() {
14+
process.stdout.write('mutating\n');
15+
await new Promise(() => {});
16+
},
17+
}),
18+
operationTimeoutMilliseconds: 10 * 60 * 1000,
19+
});
20+
21+
await service.restart();
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// @ts-check
2+
3+
import { expect, test } from 'bun:test';
4+
import { spawn } from 'node:child_process';
5+
import { mkdtemp, rm } from 'node:fs/promises';
6+
import { tmpdir } from 'node:os';
7+
import { join } from 'node:path';
8+
import { fileURLToPath } from 'node:url';
9+
import { createLifecycleService } from '../../src/supervision/service.js';
10+
import { lifecycleSnapshot, timestamp } from '../desktop/fixtures.js';
11+
12+
test.skipIf(process.platform === 'win32')(
13+
'refuses a real process contender, keeps reads live, and recovers after SIGKILL',
14+
async () => {
15+
const directory = await mkdtemp(join(tmpdir(), 'portreeve-service-process-'));
16+
const lockPath = join(directory, 'runtime', 'lifecycle.sock');
17+
const helper = fileURLToPath(
18+
new URL('../fixtures/lifecycle-service-holder.js', import.meta.url),
19+
);
20+
const holder = spawn(process.execPath, [helper, lockPath], {
21+
stdio: ['ignore', 'pipe', 'pipe'],
22+
});
23+
try {
24+
await waitForOutput(holder, 'mutating\n');
25+
let enteredStop = false;
26+
const contender = serviceFor(lockPath, {
27+
async stop() {
28+
enteredStop = true;
29+
return { changed: true };
30+
},
31+
});
32+
const startedAt = performance.now();
33+
const refused = await contender.stop();
34+
expect(performance.now() - startedAt).toBeLessThan(1_000);
35+
expect(enteredStop).toBe(false);
36+
expect(refused).toMatchObject({
37+
operation: 'stop',
38+
outcome: 'refused',
39+
changed: false,
40+
error: { code: 'lifecycle_busy' },
41+
});
42+
expect((await contender.status()).mode).toBe('supervised');
43+
expect(await contender.previewPurge()).toMatchObject({
44+
operation: 'purge',
45+
dryRun: true,
46+
allowed: true,
47+
});
48+
49+
holder.kill('SIGKILL');
50+
await waitForExit(holder);
51+
52+
let enteredRestart = false;
53+
const nextLaunch = serviceFor(lockPath, {
54+
async restart() {
55+
enteredRestart = true;
56+
},
57+
});
58+
expect(await nextLaunch.restart()).toMatchObject({
59+
operation: 'restart',
60+
outcome: 'succeeded',
61+
changed: true,
62+
error: null,
63+
});
64+
expect(enteredRestart).toBe(true);
65+
expect((await nextLaunch.status()).mode).toBe('supervised');
66+
} finally {
67+
if (holder.exitCode === null && holder.signalCode === null) {
68+
holder.kill('SIGKILL');
69+
await waitForExit(holder);
70+
}
71+
await rm(directory, { recursive: true, force: true });
72+
}
73+
},
74+
);
75+
76+
/** @param {string} lockPath @param {Record<string, unknown>} operations */
77+
function serviceFor(lockPath, operations) {
78+
return createLifecycleService({
79+
manager: /** @type {any} */ ({
80+
paths: { lifecycleLockPath: lockPath },
81+
status: async () => lifecycleSnapshot(),
82+
previewPurge: async () => ({
83+
operation: 'purge',
84+
dryRun: true,
85+
allowed: true,
86+
confirmationToken: 'a'.repeat(64),
87+
root: '/isolated/portreeve',
88+
marker: null,
89+
status: lifecycleSnapshot(),
90+
paths: [],
91+
refused: [],
92+
}),
93+
...operations,
94+
}),
95+
operationTimeoutMilliseconds: 5_000,
96+
readTimeoutMilliseconds: 2_000,
97+
recoveryTimeoutMilliseconds: 2_000,
98+
now: () => new Date(timestamp),
99+
});
100+
}
101+
102+
/** @param {import('node:child_process').ChildProcess} child @param {string} expected */
103+
function waitForOutput(child, expected) {
104+
return new Promise((resolvePromise, reject) => {
105+
let output = '';
106+
let settled = false;
107+
const timeout = setTimeout(
108+
() => reject(new Error(`Lifecycle holder did not emit ${expected.trim()}.`)),
109+
3_000,
110+
);
111+
/** @param {() => void} callback */
112+
const settle = (callback) => {
113+
if (settled) return;
114+
settled = true;
115+
clearTimeout(timeout);
116+
child.removeListener('exit', onExit);
117+
callback();
118+
};
119+
/** @param {number|null} code @param {NodeJS.Signals|null} signal */
120+
const onExit = (code, signal) =>
121+
settle(() =>
122+
reject(
123+
new Error(
124+
`Lifecycle holder exited before readiness (${String(code ?? signal)}).`,
125+
),
126+
),
127+
);
128+
child.stdout?.setEncoding('utf8');
129+
child.stdout?.on('data', (chunk) => {
130+
output += chunk;
131+
if (output.includes(expected)) settle(() => resolvePromise(undefined));
132+
});
133+
child.once('exit', onExit);
134+
});
135+
}
136+
137+
/** @param {import('node:child_process').ChildProcess} child */
138+
function waitForExit(child) {
139+
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
140+
return new Promise((resolvePromise) => child.once('exit', resolvePromise));
141+
}

0 commit comments

Comments
 (0)