Skip to content
Merged
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 packages/mobile/src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ export default function HomeScreen() {
<SafeAreaView style={styles.page}>
<StartMutationRecoveryBlockedAttention
message={mutationSnapshot.message}
online={state.phase === 'online'}
onContinueWithoutRecovery={() => {
startMutation.continueWithoutRecovery();
}}
/>
</SafeAreaView>
);
Expand Down
4 changes: 4 additions & 0 deletions packages/mobile/src/app/start/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,10 @@ export default function StartEventScreen() {
<SafeAreaView style={styles.page}>
<StartMutationRecoveryBlockedAttention
message={mutationSnapshot.message}
online={state.phase === 'online'}
onContinueWithoutRecovery={() => {
startMutation.continueWithoutRecovery();
}}
/>
</SafeAreaView>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -672,4 +672,28 @@ describe('start mutation attention presentation', () => {
expect(clear.props.accessibilityState).toEqual({ disabled: true });
expect(clear.props.disabled).toBe(true);
});

test('gives the recovery-blocked screen a way out', () => {
let continued = 0;
const result = StartMutationRecoveryBlockedAttention({
message: 'PSD EOC cannot safely read or retain the prior start request.',
onContinueWithoutRecovery: () => {
continued += 1;
},
}) as Element;
const text = normalizedText(result);
const action = renderedElements(result).find(
(node) =>
node.type === 'Pressable' &&
node.props.accessibilityLabel === 'Continue without recovery',
);

if (action === undefined) {
throw new Error('The recovery-blocked screen still has no way out.');
}
press(action);
expect(continued).toBe(1);
expect(text).toContain('nothing already stored is deleted');
expect(text).toContain('will not be recoverable here');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,8 @@ export interface OtherSessionStartMutationAttentionProps {

export interface StartMutationRecoveryBlockedAttentionProps {
readonly message: string;
readonly online?: boolean;
readonly onContinueWithoutRecovery?: () => void;
readonly testID?: string;
}

Expand Down Expand Up @@ -534,6 +536,8 @@ export function StartMutationRecoveryCheckingAttention({
/** Identity-free hard stop when durable recovery storage cannot be trusted. */
export function StartMutationRecoveryBlockedAttention({
message,
online = true,
onContinueWithoutRecovery,
testID,
}: StartMutationRecoveryBlockedAttentionProps) {
return (
Expand All @@ -560,6 +564,35 @@ export function StartMutationRecoveryBlockedAttention({
No request will be sent or queued while recovery is unavailable.
</Text>
</View>

{onContinueWithoutRecovery === undefined ? null : (
<View style={styles.actions}>
<Text style={[styles.acknowledgeNote, styles.neutralText]}>
You can carry on without recovery on this device. Start and join
work again, and nothing already stored is deleted. If the app closes
while a request is in flight, its outcome will not be recoverable
here.
</Text>
<Pressable
accessibilityHint="Restores start and join on this device without durable recovery. Nothing already stored is deleted."
accessibilityLabel="Continue without recovery"
accessibilityRole="button"
accessibilityState={{ disabled: !online }}
disabled={!online}
onPress={onContinueWithoutRecovery}
style={({ pressed }) => [
styles.acknowledgeAction,
styles.neutralAcknowledgeAction,
pressed && online && styles.pressed,
!online && styles.disabled,
]}
>
<Text style={[styles.acknowledgeActionText, styles.neutralHeading]}>
Continue without recovery
</Text>
</Pressable>
</View>
)}
</ScrollView>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1070,4 +1070,43 @@ describe('mapStartMutationError', () => {
expect(signedOut.acknowledgeUnresolved(OWNER)).toBe(false);
expect(persistence.record).not.toBeNull();
});

test('lets the operator carry on when the device store cannot be written', async () => {
const persistence = new MemoryPersistence();
persistence.failWrite = true;
const coordinator = new StartMutationCoordinator(persistence);
online(coordinator);
const blocked = coordinator.submit(
activationSubmission(() => Promise.resolve(activationResult())),
);
expect(blocked.accepted).toBe(false);
expect(coordinator.getSnapshot().phase).toBe('recovery-blocked');

expect(coordinator.continueWithoutRecovery(OWNER)).toBe(true);
expect(coordinator.getSnapshot().phase).toBe('idle');

// The broken store is no longer consulted, so a new emergency can be
// raised instead of the device refusing every start until it heals.
const next = coordinator.submit(
activationSubmission(() => Promise.resolve(activationResult())),
);
expect(next.accepted).toBe(true);
if (next.accepted) await next.completion;
expect(coordinator.getSnapshot().phase).toBe('succeeded');
});

test('refuses to carry on without recovery unless blocked and signed in', () => {
const persistence = new MemoryPersistence();
persistence.failWrite = true;
const coordinator = new StartMutationCoordinator(persistence);
online(coordinator);
expect(coordinator.continueWithoutRecovery(OWNER)).toBe(false);

coordinator.submit(
activationSubmission(() => Promise.resolve(activationResult())),
);
expect(coordinator.getSnapshot().phase).toBe('recovery-blocked');
expect(coordinator.continueWithoutRecovery(OTHER_OWNER)).toBe(false);
expect(coordinator.getSnapshot().phase).toBe('recovery-blocked');
});
});
50 changes: 42 additions & 8 deletions packages/mobile/src/lib/start/start-mutation-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,18 @@ export class StartMutationCoordinator {
private readonly persistence: StartMutationPersistence | null = null,
) {}

private persistenceSuspended = false;

/**
* The durable store, or nothing once the operator has chosen to carry on
* without device recovery. Suspension lasts for this process only and never
* rewrites or deletes what is already stored; it stops this coordinator
* depending on a store that has proven it cannot be read or written.
*/
private get store(): StartMutationPersistence | null {
return this.persistenceSuspended ? null : this.persistence;
}

public getSnapshot = (): StartMutationSnapshot => this.snapshot;

public subscribe = (listener: StartMutationListener): (() => void) => {
Expand Down Expand Up @@ -569,9 +581,9 @@ export class StartMutationCoordinator {

private persistCurrentStateOrBlock(): boolean {
const record = this.recoveryRecord();
if (record === null || this.persistence === null) return true;
if (record === null || this.store === null) return true;
try {
this.persistence.write(record);
this.store.write(record);
return true;
} catch {
this.state = RECOVERY_BLOCKED_INTERNAL_STATE;
Expand All @@ -581,9 +593,9 @@ export class StartMutationCoordinator {
}

private clearDurableRecordOrBlock(): boolean {
if (this.persistence === null) return true;
if (this.store === null) return true;
try {
this.persistence.clear();
this.store.clear();
return true;
} catch {
this.state = RECOVERY_BLOCKED_INTERNAL_STATE;
Expand All @@ -594,10 +606,10 @@ export class StartMutationCoordinator {

/** Hydrates retained mutation truth after local authentication succeeds. */
public hydrate(notify = true): boolean {
if (this.persistence === null || this.state.phase !== 'idle') return false;
if (this.store === null || this.state.phase !== 'idle') return false;
let record: StartMutationRecoveryRecord | null;
try {
record = this.persistence.read();
record = this.store.read();
} catch {
this.state = RECOVERY_BLOCKED_INTERNAL_STATE;
this.refreshSnapshot(notify);
Expand Down Expand Up @@ -719,9 +731,9 @@ export class StartMutationCoordinator {
? submission.activationEvidence
: null;

if (this.persistence !== null) {
if (this.store !== null) {
try {
this.persistence.write(
this.store.write(
Object.freeze({
phase: 'unresolved',
owner,
Expand Down Expand Up @@ -900,6 +912,28 @@ export class StartMutationCoordinator {
return true;
}

/**
* Leaves the blocked state on an explicit human decision, carrying on with
* no durable recovery for the rest of this process. The store has already
* failed a read or a write here, so there is nothing trustworthy to keep;
* the alternative was a device that refused every start and join until the
* keychain healed, which for an emergency tool is the worse failure. What
* is already stored is left untouched rather than deleted.
*/
public continueWithoutRecovery(ownerInput: StartMutationOwner): boolean {
const owner = validatedOwner(ownerInput);
if (
this.state.phase !== 'recovery-blocked' ||
!sameOwner(this.onlineOwner, owner)
) {
return false;
}
this.persistenceSuspended = true;
this.state = IDLE_INTERNAL_STATE;
this.refreshSnapshot(true);
return true;
}

/** Publishes a pre-transport denial without creating a key or running work. */
public reportDeniedSubmission(
ownerInput: StartMutationOwner,
Expand Down
11 changes: 11 additions & 0 deletions packages/mobile/src/lib/start/start-mutation-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface StartMutationContextValue {
readonly submitJoin: (input: SubmitStartJoinInput) => StartMutationAdmission;
readonly acknowledge: () => boolean;
readonly acknowledgeUnresolved: () => boolean;
readonly continueWithoutRecovery: () => boolean;
readonly claimSuccessFeedback: () => StartMutationCompletion | null;
}

Expand Down Expand Up @@ -319,6 +320,13 @@ export class StartMutationProviderController {
: this.coordinator.acknowledgeUnresolved(owner);
};

public continueWithoutRecovery = (): boolean => {
const owner = this.exactLiveOwner();
return owner === null
? false
: this.coordinator.continueWithoutRecovery(owner);
};

public claimSuccessFeedback = (): StartMutationCompletion | null => {
const owner = this.exactLiveOwner();
return owner === null ? null : this.coordinator.claimSuccessFeedback(owner);
Expand Down Expand Up @@ -381,6 +389,9 @@ export function StartMutationProvider({ children }: PropsWithChildren) {
acknowledgeUnresolved: () =>
snapshot.phase !== 'checking-recovery' &&
controller.acknowledgeUnresolved(),
continueWithoutRecovery: () =>
snapshot.phase !== 'checking-recovery' &&
controller.continueWithoutRecovery(),
claimSuccessFeedback: () =>
snapshot.phase === 'checking-recovery'
? null
Expand Down