Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2096009
fix(android): keep close --shutdown's IME restore out of the settings…
thymikee Oct 8, 2026
10b4126
fix(android): carry an aborted flush settle into the next close
thymikee Oct 8, 2026
6c5221b
fix(android): keep an uninspected close from erasing a retained marker
thymikee Oct 8, 2026
175fa4d
refactor(android): keep the restore sequencing helpers under complexi…
thymikee Oct 8, 2026
3bbda23
fix(android): coalesce flush-settle windows into one deadline per device
thymikee Oct 8, 2026
fcf968b
fix(android): register the flush window on every confirmed restore
thymikee Oct 8, 2026
83e78a7
fix(android): register the IME flush window at the write and gate eve…
thymikee Oct 8, 2026
5753cfa
fix(android): time the IME flush window on the monotonic clock
thymikee Oct 8, 2026
c352648
refactor(android): encode the monotonic clock in the flush-window mar…
thymikee Oct 9, 2026
44ca810
test(android): pin the AbortError mechanism in the cancelled-kill test
thymikee Oct 9, 2026
ab73348
test(android): bind the cancelled-kill assertion to the signal reason…
thymikee Oct 9, 2026
91d4c0b
fix(android): let a mid-wait restore extend the pending kill's flush …
thymikee Oct 9, 2026
7c82975
docs(android): record the daemon-restart boundary of the flush window
thymikee Oct 9, 2026
ce40232
docs(android): point the flush-window restart boundary at follow-up #…
thymikee Oct 9, 2026
4d1b6d0
fix(android): track in-flight ime set so a racing kill cannot read idle
thymikee Oct 9, 2026
248aa0b
fix(android): satisfy lint and fallow on the flush-window wait, pin t…
thymikee Oct 9, 2026
b0516fe
test(android): derive the ime-restore flush floors proportionally too
thymikee Oct 9, 2026
b79910d
fix(android): an issued emulator restore owes the flush hold whatever…
thymikee Oct 9, 2026
5d679ca
refactor(android): strip review history from flush-window comments, p…
thymikee Oct 9, 2026
663f11d
fix(android): type restore-mark provenance so only confirmed windows …
thymikee Oct 9, 2026
b36626a
test(android): share the restore-mark seeder and bound the owed-hold …
thymikee Oct 9, 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
5 changes: 4 additions & 1 deletion packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,10 @@ export type AndroidApplicationTools = Readonly<{
applyRuntimeHints(device: DeviceInfo, input: RuntimeHintsApplicationInput): Promise<void>;
clearRuntimeHints(device: DeviceInfo, input: RuntimeHintsApplicationInput): Promise<void>;
activateTestIme(device: DeviceInfo, input: Readonly<{ stateDir: string }>): Promise<void>;
restoreTestIme(device: DeviceInfo, input: Readonly<{ stateDir: string }>): Promise<void>;
restoreTestIme(
device: DeviceInfo,
input: Readonly<{ stateDir: string; shutdownTarget: boolean; signal: AbortSignal }>,
): Promise<void>;
recoverTestImeStartup(input: Readonly<{ stateDir: string }>): Promise<void>;
hasTestImeRecoveryEvidence(stateDir: string): Promise<boolean>;
}>;
Expand Down
9 changes: 9 additions & 0 deletions packages/platform-android/src/ime-device.fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import type { AndroidAdbExecutor, AndroidAdbExecutorResult } from './adb-transport.ts';
import { testImeRestoreMarks } from './ime-state.ts';

// An in-memory device speaking the exact shell surfaces the IME lifecycle touches: the
// `settings secure` namespace and `ime enable/disable/set`. Shared by the colocated IME module tests.

// Places a restore mark at an exact age on the register of flush windows. Default provenance
// is confirmed: a registered window normally comes from a confirmed restore, and wait-
// derivation tests read only atPerfMs; tests pinning marker-clear eligibility pass provenance
// explicitly. Lives here so a mark-SHAPE change lands in one edit.
export function seedRestoreMark(serial: string, ageMs: number, confirmed = true): void {
testImeRestoreMarks.set(serial, { atPerfMs: performance.now() - ageMs, confirmed });
}

export type FakeImeDeviceState = {
settings: Map<string, string>;
imeSetFails?: boolean;
Expand Down
349 changes: 342 additions & 7 deletions packages/platform-android/src/ime-restore.test.ts

Large diffs are not rendered by default.

145 changes: 108 additions & 37 deletions packages/platform-android/src/ime-restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,20 @@ import {
readAndroidDefaultInputMethod,
readAndroidTestImeDeviceRecord,
} from './ime-settings-record.ts';
import { activeTestImeDevices, withAndroidTestImeRecoveryLock } from './ime-state.ts';
import {
activeTestImeDevices,
awaitTestImeFlushWindow,
beginTestImeRestoreWrite,
registerTestImeRestore,
withAndroidTestImeRecoveryLock,
type TestImeFlushWait,
} from './ime-state.ts';

// Restore and startup orphan recovery: undo the helper switch exactly when it is safe, keep
// durable evidence until the device is observed clean.

export type AndroidTestImeRestoreReason =
| 'not-activated-here'
| 'no-record'
| 'record-unreadable'
| 'helper-not-active'
Expand All @@ -33,43 +41,77 @@ export type AndroidTestImeRestoreResult = {
reason: AndroidTestImeRestoreReason;
};

// One marker-clear rule, owned here and used by both callers of the inner restore. A reason
// from an inspected device proves recovery complete once this call's flush wait is not
// abandoned: a kill-bound close must reach a covered outcome (an aborted wait is never a
// completed settle), while an ordinary close or the startup scan owe no wait at all — neither
// kills, and the device's window stays registered for the next kill-bound path to wait out. A
// nothing-inspected `not-activated-here` close earns the clear only on 'covered-confirmed':
// it saw no device, so the sole evidence that retrying is safe is that the covered window
// belonged to a write that confirmed. An issued-but-unconfirmed write leaves the helper
// possibly displaced and its persisted target intact, and the marker must survive for the
// startup scan to retry.
function isRecoveryMarkerClearEarned(
reason: AndroidTestImeRestoreReason,
settleOutcome: TestImeFlushWait,
): boolean {
if (isDeviceRecoveryComplete(reason)) return settleOutcome !== 'aborted';
return reason === 'not-activated-here' && settleOutcome === 'covered-confirmed';
}

export async function restoreAndroidTestIme(
device: DeviceInfo,
options: { stateDir: string },
options: { stateDir: string; shutdownTarget?: boolean; signal?: AbortSignal },
): Promise<AndroidTestImeRestoreResult> {
return await withAndroidTestImeRecoveryLock(options.stateDir, device.id, async () => {
const deviceKey = getAndroidImeHelperDeviceKey(device);
// Skip devices this process never activated (orphans from another process are handled by
// restoreOrphanedAndroidTestImeOnDaemonStartup and the doctor check).
if (!activeTestImeDevices.has(deviceKey)) {
return { restored: false, reason: 'no-record' };
}
// Drop the owned-flag first so restoreAndroidTestImeFor's "owned by a live session" guard does
// not skip this intentional close-time restore.
activeTestImeDevices.delete(deviceKey);
const adb = resolveAndroidAdbExecutor(device);
const result = await restoreAndroidTestImeFor(adb, device);
if (isDeviceRecoveryComplete(result.reason)) {
const result = await restoreOwnedAndroidTestIme(device);
// Only a kill-bound close waits: it returns after the device's flush window is covered, so
// the finalizer's kill lands after the provider's write. Ordinary closes never sleep.
const settleOutcome: TestImeFlushWait =
options.shutdownTarget === true
? await awaitTestImeFlushWindow(device.id, options.signal)
: 'idle';
if (isRecoveryMarkerClearEarned(result.reason, settleOutcome)) {
await requireAndroidAdbHost().imeRecoveryMarkers.clear(options.stateDir, device.id);
}
return result;
});
}

// A device no longer needs recovery once the helper is confirmed off it (restored, or already not
// the active IME, or no record). A `set-failed` (still stuck), `record-unreadable` (the device may be
// on Android's fallback IME) or `owned-by-live-session` (a live session will restore it on close)
// keeps its pending marker for a later retry.
// Restore this process's own device, or report that it never activated one. For an unactivated
// device nothing is inspected (orphans from another process are handled by
// restoreOrphanedAndroidTestImeOnDaemonStartup and the doctor check), so recovery stays unknown.
async function restoreOwnedAndroidTestIme(
device: DeviceInfo,
): Promise<AndroidTestImeRestoreResult> {
const deviceKey = getAndroidImeHelperDeviceKey(device);
if (!activeTestImeDevices.has(deviceKey)) {
return { restored: false, reason: 'not-activated-here' };
}
// Drop the owned-flag first so restoreAndroidTestImeFor's "owned by a live session" guard does
// not skip this intentional close-time restore. The IME really is back on the previous keyboard,
// so text routing must stop preferring the helper channel even on an abort.
activeTestImeDevices.delete(deviceKey);
return await restoreAndroidTestImeFor(resolveAndroidAdbExecutor(device), device);
}

// The device was inspected and the helper is confirmed off it (restored, already not the active
// IME, or no rebind record). A `set-failed` (still stuck), `record-unreadable` (the device may be
// on Android's fallback IME), `owned-by-live-session` (a live session will restore it on close)
// or `not-activated-here` (nothing was inspected) keeps its pending marker for a later retry.
function isDeviceRecoveryComplete(reason: AndroidTestImeRestoreReason): boolean {
return reason === 'ok' || reason === 'helper-not-active' || reason === 'no-record';
}

// Undo the helper switch on one device. Invariants the review requires:
// Undo the helper switch on one device. Invariants:
// - Never restore a device a live session in this process owns (the fire-and-forget startup race).
// - Only touch the IME when the helper is STILL the active input method, or the device record
// marks an unconfirmed rebind. If the user (or a concurrent session) switched away, leave it.
// - Only clear the persisted recovery value AFTER confirming the previous IME is actually
// restored (read-back). A failed `ime set` keeps the value so recovery can retry.
// - Every issued emulator restore registers its flush window here, the only site that knows
// the write was made, so no caller — close-time or startup-orphan — and no future call site
// can restore without registering.
async function restoreAndroidTestImeFor(
adb: AndroidAdbExecutor,
device: DeviceInfo,
Expand Down Expand Up @@ -104,26 +146,51 @@ async function restoreAndroidTestImeFor(
});
return { restored: false, previousIme, reason: 'helper-not-active' };
}
await runAdbShell(adb, ['ime', 'set', previousIme], { allowFailure: true, timeoutMs: 10_000 });
const afterIme = await readAndroidDefaultInputMethod(adb);
if (afterIme !== previousIme) {
// Restore did not take effect. Keep the persisted value so recovery can retry — clearing it
// now would permanently strand the user on the helper IME.
// The provider's window opens when the device accepts this write, so the pending entry
// opens before issuing it: a kill-bound waiter drains in-flight writes before consulting
// marks and never reads the gap as "nothing owed". A `finally` close runs after the try
// body's registration, so a waiter never sees the entry closed while its mark is absent.
const pendingWriteOpens = beginTestImeRestoreWrite(device.id);
let writeConfirmed = false;
try {
await runAdbShell(adb, ['ime', 'set', previousIme], {
allowFailure: true,
timeoutMs: 10_000,
});
const afterIme = await readAndroidDefaultInputMethod(adb);
if (afterIme !== previousIme) {
// Restore did not take effect. Keep the persisted value so recovery can retry — clearing
// it now would permanently strand the user on the helper IME.
emitAndroidAdbDiagnostic({
level: 'warn',
phase: 'android_test_ime_restore_failed',
data: { device: deviceLabel, previousIme, afterIme },
});
return { restored: false, previousIme, reason: 'set-failed' };
}
// Confirmed back on the previous IME — the only outcome that earns clearing the persisted
// record and the only mark provenance that may justify clearing another close's marker.
// The flush hold attaches to the ISSUED write regardless (see the `finally`).
writeConfirmed = true;
// Now it is safe to drop the recovery value.
await clearPersistedPreviousIme(adb).catch(() => {});
await clearPersistedRebindDisplacement(adb).catch(() => {});
emitAndroidAdbDiagnostic({
level: 'warn',
phase: 'android_test_ime_restore_failed',
data: { device: deviceLabel, previousIme, afterIme },
phase: 'android_test_ime_restored',
data: { device: deviceLabel, previousIme },
});
return { restored: false, previousIme, reason: 'set-failed' };
return { restored: true, previousIme, reason: 'ok' };
} finally {
// An issued emulator restore owes the kill-bound flush hold whatever the outcome: a
// readback mismatch or post-dispatch throw cannot prove the provider never accepted the
// write. Registering here, before the pending entry closes, keeps "a drained waiter never
// sees closed-but-unregistered" a property of this one site; the mark carries whether
// this write confirmed, which is the only fact that may retire retry evidence.
if (device.kind === 'emulator') {
registerTestImeRestore(device.id, writeConfirmed);
}
pendingWriteOpens();
}
// Confirmed back on the previous IME — now it is safe to drop the recovery value.
await clearPersistedPreviousIme(adb).catch(() => {});
await clearPersistedRebindDisplacement(adb).catch(() => {});
emitAndroidAdbDiagnostic({
phase: 'android_test_ime_restored',
data: { device: deviceLabel, previousIme },
});
return { restored: true, previousIme, reason: 'ok' };
}

// Best-effort: restore any test IME left active by a crashed daemon run. Gated on the device-scoped
Expand Down Expand Up @@ -177,7 +244,11 @@ export async function restoreOrphanedAndroidTestImeOnDaemonStartup(params: {
data: { device: serial, previousIme: result.previousIme },
});
}
if (isDeviceRecoveryComplete(result.reason)) {
// The inner restore has registered the device's flush window; startup never kills and
// never waits, so its settle outcome is 'idle' — the same single clear rule the close
// path applies, not a second notion of "done". The deadline stays in the map for the
// next kill-bound path (close --shutdown or the shutdown runtime) to wait out.
if (isRecoveryMarkerClearEarned(result.reason, 'idle')) {
await markers.clear(params.stateDir, serial);
}
});
Expand Down
Loading
Loading