Stop notifications after removing a host (#3176)

* fix(notifications): stop pushes after host removal

Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable.

* fix(notifications): make token revocation bounded and durable
This commit is contained in:
Mohamed Boudra
2026-08-10 23:33:31 +02:00
committed by GitHub
parent 29ae25d7aa
commit 6aa9f92156
33 changed files with 702 additions and 227 deletions
+53
View File
@@ -967,6 +967,59 @@ test("keeps the transport connected when a session RPC ping times out", async ()
expect(client.getConnectionState().status).toBe("connected");
});
test("waits for the daemon to acknowledge push token revocation", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_push_revocation",
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { pushTokenRevocation: true } });
await connectPromise;
const revocation = client.unregisterPushToken("ExponentPushToken[test-device]");
const request = parseSentFrame(mock.sent.at(-1));
expect(request).toMatchObject({
type: "push.unregister.request",
token: "ExponentPushToken[test-device]",
});
mock.triggerMessage(
wrapSessionMessage({
type: "push.unregister.response",
payload: { requestId: request.requestId },
}),
);
await revocation;
});
test("bounds the wait for push token revocation", async () => {
useHeartbeatClock();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_push_revocation_timeout",
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: { pushTokenRevocation: true } });
await connectPromise;
const revocation = client.unregisterPushToken("ExponentPushToken[test-device]");
const rejection = expect(revocation).rejects.toThrow("Timeout waiting for message (2000ms)");
await vi.advanceTimersByTimeAsync(1_999);
expect(client.getConnectionState().status).toBe("connected");
await vi.advanceTimersByTimeAsync(1);
await rejection;
expect(client.getConnectionState().status).toBe("connected");
});
test("defaults session RPC waiters to sixty seconds", async () => {
useHeartbeatClock();
const logger = createMockLogger();
+11
View File
@@ -876,6 +876,7 @@ function toTimeoutError(error: unknown, label: string, timeoutMs: number): Error
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000;
const PUSH_TOKEN_REVOCATION_TIMEOUT_MS = 2_000;
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
@@ -1840,6 +1841,16 @@ export class DaemonClient {
});
}
async unregisterPushToken(token: string): Promise<void> {
const requestId = this.createRequestId();
await this.sendCorrelatedSessionRequest({
requestId,
message: { type: "push.unregister.request", token, requestId },
responseType: "push.unregister.response",
timeout: PUSH_TOKEN_REVOCATION_TIMEOUT_MS,
});
}
async ping(params?: { requestId?: string; timeoutMs?: number }): Promise<{
requestId: string;
clientSentAt: number;