Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
be787ae
fix(ios): stop a warm runner from silently rebooting a shut-down simu…
thymikee Oct 8, 2026
4315ff9
test(ios): cover the warm-runner destination watch (#3321)
thymikee Oct 9, 2026
9f58f7f
chore(gates): classify the warm-runner notice module and edge (#3321)
thymikee Oct 9, 2026
bc01130
fix(ios): classify destination loss by device state and kill the reta…
thymikee Oct 9, 2026
68b92ee
refactor(ios): split destination-loss classification into a boot read…
thymikee Oct 9, 2026
5eb630b
fix(ios): own warm-runner retention in one place and classify replace…
thymikee Oct 9, 2026
97360e1
test(ios): pin that a destination lost during retention stops the run…
thymikee Oct 9, 2026
b439430
fix(ios): restore a released retention window only while no one has t…
thymikee Oct 9, 2026
fbbf157
test(ios): assert the typed ownership reason and the reused session s…
thymikee Oct 9, 2026
dd52602
refactor(ios): one Simulator state probe, one env reader, and a watch…
thymikee Oct 10, 2026
97f47a2
docs(ios): say what the state probe throws, what the env overrides ac…
thymikee Oct 10, 2026
e2fb955
fix(ios): a state probe that times out stops the runner as runner_unr…
thymikee Oct 10, 2026
791bdd6
docs(ios): drop a stray bullet marker in the watch comment (#3321)
thymikee Oct 10, 2026
9c71e66
fix(ios): give an unverifiable simulator state its own typed reason a…
thymikee Oct 10, 2026
dd9c0ae
docs(ios): wrap the watch and probe comments to the 100-column width …
thymikee Oct 10, 2026
fe6a625
refactor(ios): stop a retained runner on any lost listener instead of…
thymikee Oct 10, 2026
c1d59f1
refactor(apple): give retained runners one lifecycle owner
thymikee Oct 10, 2026
f234d54
chore(gates): remove retired warm-loss notice ownership
thymikee Oct 10, 2026
06362ca
fix(apple): address retained runner review findings
thymikee Oct 10, 2026
8afe959
docs(apple): wrap retained runner lifecycle explanation
thymikee Oct 10, 2026
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
11 changes: 8 additions & 3 deletions docs/adr/0005-ios-runner-interaction-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,14 @@ If xcodebuild still exits for another reason, the next command detects the stale
process/liveness checks and avoids the old 15-second graceful-shutdown wait. The remaining latency is
fresh xcodebuild runner startup, not a stale transport stall.

A `close` then `open` on a runner still draining abandoned main-thread work now pays a fresh runner boot
instead of inheriting the stalled one, so the wedge no longer survives the close/open cycle. Runners
that were never reported busy keep the existing warm-reuse path unchanged.
A `close` then `open` on a runner still draining abandoned main-thread work now pays a fresh runner
boot instead of inheriting the stalled one, so the wedge no longer survives the close/open cycle.
Runners that are ready and have no unanswered exchanges can remain warm when retention is requested;
iOS-family simulators additionally require an observed live listener. Close retires unfinished
startup and its recovery authority. The retained generation owns its idle timer and listener
observation; successful reuse ends retention, listener loss or idle expiry disposes that generation,
and graceful daemon shutdown stops idle retained generations before exiting. Active ready
generations retain the existing handoff policy.

The daemon no longer models a generic "recent success" cache as a runner-health signal. A proven
healthy mutating response for the same app — recorded only after the `runnerFatal` check and only
Expand Down
5 changes: 3 additions & 2 deletions docs/agents/device-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ physical devices. Live verification steps apply when exercising a device-facing
- Before any Android verification from source: `pnpm build`, `pnpm build:android`, `pnpm clean:daemon`.
`build:android` refreshes and verifies both bundled Android helper artifacts for the current
package version.
- Graceful `shutdown` hands off a healthy runner that already answered a command, on the simulator
and physical iOS lanes alike; a new daemon may adopt the old binary. After
- Graceful `shutdown` hands off an active healthy runner that already answered a command, on the
simulator and physical iOS lanes alike; a new daemon may adopt the old binary. A runner retained
idle after `close` is stopped before the daemon exits. After
Swift runner changes, run `pnpm build:xcuitest` before verification. Use the session cleanup
procedure below if ownership is stuck. The physical handoff's device steps are
`docs/evidence/ios-physical-runner-handoff-2026-09-19.md`; nothing in it is proven until someone
Expand Down
89 changes: 89 additions & 0 deletions packages/kernel/src/keyed-lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,92 @@ test('withKeyedLock allows reentrant work for the same key while holding the out

assert.deepEqual(order, ['outer-start', 'inner', 'outer-end', 'competing']);
});

test('a deferred descendant of a released lock queues behind its current owner', async () => {
const locks = new Map<string, Promise<unknown>>();
const order: string[] = [];
let timerAttempted!: () => void;
const attempted = new Promise<void>((resolve) => {
timerAttempted = resolve;
});
let descendant!: Promise<void>;
await withKeyedLock(locks, 'device-a', async () => {
descendant = new Promise<void>((resolve, reject) => {
setTimeout(() => {
timerAttempted();
withKeyedLock(locks, 'device-a', async () => {
order.push('descendant');
}).then(resolve, reject);
}, 0);
});
});
let releaseOwner!: () => void;
const ownerGate = new Promise<void>((resolve) => {
releaseOwner = resolve;
});
let ownerStarted!: () => void;
const started = new Promise<void>((resolve) => {
ownerStarted = resolve;
});
const owner = withKeyedLock(locks, 'device-a', async () => {
order.push('owner');
ownerStarted();
await ownerGate;
order.push('released');
});
await started;
await attempted;
await new Promise<void>((resolve) => setImmediate(resolve));
assert.deepEqual(order, ['owner']);
releaseOwner();
await Promise.all([owner, descendant]);
assert.deepEqual(order, ['owner', 'released', 'descendant']);
});

test.each([false, true])(
'reentrant descendants settle before the next owner (reject=%s)',
async (reject) => {
const locks = new Map<string, Promise<unknown>>();
const order: string[] = [];
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let markStarted!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
let descendant!: Promise<void>;
const owner = withKeyedLock(locks, 'device-a', async () => {
queueMicrotask(() => {
descendant = withKeyedLock(locks, 'device-a', async () => {
order.push('descendant');
markStarted();
await gate;
order.push('finished');
void withKeyedLock(locks, 'device-a', async () => {
await new Promise<void>((resolve) => setImmediate(resolve));
order.push('nested');
});
if (reject) throw new Error('descendant failed');
});
void descendant.catch(() => {});
});
});
const competing = withKeyedLock(locks, 'device-a', async () => {
order.push('competing');
});
await started;
await new Promise<void>((resolve) => setImmediate(resolve));
const beforeRelease = [...order];
release();
const [, outcome] = await Promise.all([
owner,
descendant.catch((error: Error) => error),
competing,
]);
assert.equal(outcome instanceof Error, reject);
assert.deepEqual(beforeRelease, ['descendant']);
assert.deepEqual(order, ['descendant', 'finished', 'nested', 'competing']);
},
);
32 changes: 29 additions & 3 deletions packages/kernel/src/keyed-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { AsyncLocalStorage } from 'node:async_hooks';
type LockFrame = {
locks: Map<string, Promise<unknown>>;
key: string;
active: boolean;
descendants: Set<Promise<void>>;
};

const keyedLockStorage = new AsyncLocalStorage<LockFrame[]>();
Expand All @@ -13,13 +15,37 @@ export async function withKeyedLock<T>(
task: () => Promise<T>,
): Promise<T> {
const activeLocks = keyedLockStorage.getStore() ?? [];
if (activeLocks.some((entry) => entry.locks === locks && entry.key === key)) {
return await task();
const held = activeLocks.find(
(entry) => entry.active && entry.locks === locks && entry.key === key,
);
if (held) {
let markSettled!: () => void;
const settled = new Promise<void>((resolve) => {
markSettled = resolve;
});
held.descendants.add(settled);
try {
return await task();
} finally {
held.descendants.delete(settled);
markSettled();
}
}
const previous = locks.get(key) ?? Promise.resolve();
const current = previous
.catch(() => {})
.then(() => keyedLockStorage.run([...activeLocks, { locks, key }], task));
.then(async () => {
const frame: LockFrame = { locks, key, active: true, descendants: new Set() };
try {
return await keyedLockStorage.run(
[...activeLocks.filter((entry) => entry.active), frame],
task,
);
} finally {
while (frame.descendants.size > 0) await Promise.all(frame.descendants);
frame.active = false;
Comment thread
thymikee marked this conversation as resolved.
}
});
locks.set(key, current);
return current.finally(() => {
if (locks.get(key) === current) {
Expand Down
Loading
Loading