diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.test.ts b/apps/mobile/src/features/cloud/managedRelayLayer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b8d40413eb774dcfc8552c07ab6178696d9506a --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayLayer.test.ts @@ -0,0 +1,63 @@ +/// + +import * as NodeCrypto from "node:crypto"; + +import { vi } from "vite-plus/test"; +import { assert, describe, it } from "@effect/vitest"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { FetchHttpClient } from "effect/http"; + +import { cryptoLayer } from "./dpop"; +import { managedRelayClientLayer } from "./managedRelayLayer"; + +vi.mock("expo-crypto", () => ({ + CryptoDigestAlgorithm: { SHA256: "SHA-256" }, + getRandomBytes: (byteCount: number) => new Uint8Array(NodeCrypto.randomBytes(byteCount)), + getRandomBytesAsync: (byteCount: number) => + Promise.resolve(new Uint8Array(NodeCrypto.randomBytes(byteCount))), + digest: (algorithm: string, data: Uint8Array) => + Promise.resolve(new Uint8Array(NodeCrypto.createHash(algorithm).update(data).digest()).buffer), +})); + +const secureStore = new Map(); +let failNextRead = false; +vi.mock("expo-secure-store", () => ({ + getItemAsync: (key: string) => { + if (failNextRead) { + failNextRead = false; + return Promise.reject(new Error("keychain locked")); + } + return Promise.resolve(secureStore.get(key) ?? null); + }, + setItemAsync: (key: string, value: string) => { + secureStore.set(key, value); + return Promise.resolve(); + }, + deleteItemAsync: (key: string) => { + secureStore.delete(key); + return Promise.resolve(); + }, +})); + +describe("managed relay DPoP signer", () => { + it.effect("loads the proof key again after a failed read", () => + Effect.gen(function* () { + const signer = yield* ManagedRelay.ManagedRelayDpopSigner; + failNextRead = true; + const failed = yield* Effect.flip(signer.thumbprint); + assert.equal(failed._tag, "ManagedRelayDpopKeyLoadError"); + + // The failure was not kept: the next request reads the key store again. + const thumbprint = yield* signer.thumbprint; + assert.equal(yield* signer.thumbprint, thumbprint); + }).pipe( + Effect.provide( + managedRelayClientLayer("https://relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), + ), + ), + ), + ); +}); diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 2da1fa9157c65f61718cc9f032a29b1560b93858..98b4d2254f22691a6dfc686f773060504e1a1cc7 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -1,7 +1,10 @@ import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; +import * as Cache from "effect/Cache"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; @@ -11,9 +14,16 @@ const relayDpopSignerLayer = Layer.effect( ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - const loadProofKey = yield* Effect.cached( - loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), + // Keeps the loaded key for the app's lifetime. A failed or interrupted + // load is not kept, so the next relay request loads it again. + const proofKeyCache = yield* Cache.makeWith( + () => loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), + { + capacity: 1, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + }, ); + const loadProofKey = Cache.get(proofKeyCache, undefined); return ManagedRelay.ManagedRelayDpopSigner.of({ thumbprint: loadProofKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 65ffc731ddcef7d925150a9d9f326d12a248abcc..b4bf670375efac03c21984bd48958e46a240f23b 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -8,7 +8,9 @@ import * as NodePath from "node:path"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; @@ -902,6 +904,61 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { expect(resolutions).toBe(2); }), ); + + it.effect("starts a fresh read instead of joining the resolution already running", () => + Effect.gen(function* () { + const releaseStale = yield* Deferred.make(); + let resolutions = 0; + const resolve = yield* makeCachedProviderMaintenanceResolution( + Effect.suspend(() => { + resolutions += 1; + return resolutions === 1 + ? Deferred.await(releaseStale).pipe(Effect.as(manualPackageTool)) + : Effect.succeed(manualPackageTool); + }), + ); + + // An advisory read starts a resolution that is still running when the + // user clicks Update; the fresh read must not wait on or reuse it. + const advisory = yield* Effect.forkChild(resolve(), { startImmediately: true }); + const fresh = yield* Effect.forkChild(resolve({ fresh: true }), { startImmediately: true }); + // The fresh read runs its own resolution, so it finishes while the stale + // one is still waiting. + expect(resolutions).toBe(2); + expect(fresh.pollUnsafe()).toBeDefined(); + yield* Deferred.succeed(releaseStale, undefined); + expect(yield* Fiber.join(fresh)).toEqual(manualPackageTool); + yield* Fiber.join(advisory); + }), + ); + + it.effect("resolves again after the first read is interrupted", () => + Effect.gen(function* () { + const release = yield* Deferred.make(); + let resolutions = 0; + const resolve = yield* makeCachedProviderMaintenanceResolution( + Effect.suspend(() => { + resolutions += 1; + return resolutions === 1 + ? Effect.never + : Deferred.await(release).pipe(Effect.as(manualPackageTool)); + }), + ); + + const first = yield* Effect.forkChild(resolve(), { startImmediately: true }); + yield* Fiber.interrupt(first); + + // The next read resolves again, and a reader that joined it keeps it + // running when the reader that started it goes away. + const owner = yield* Effect.forkChild(resolve(), { startImmediately: true }); + const waiter = yield* Effect.forkChild(resolve(), { startImmediately: true }); + yield* Fiber.interrupt(owner); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(waiter)).toEqual(manualPackageTool); + expect(yield* resolve()).toEqual(manualPackageTool); + expect(resolutions).toBe(2); + }), + ); }); it("pins only owned package-manager installs and preserves their execution context", () => { diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index e5d48a98946593b5008893470b6a2c292e065ced..606f9a460ba3c5a11e43e1c97fb739504ceb8d6e 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -7,6 +7,7 @@ import { compareSemverVersions } from "@t3tools/shared/semver"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveCommandPath } from "@t3tools/shared/shell"; +import * as Cache from "effect/Cache"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -702,17 +703,20 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( /** * Turn a one-shot resolution into the shape drivers expose: a cached read for * advisories and a `fresh` read that update execution uses so it never trusts - * ownership derived before the user clicked. + * ownership derived before the user clicked. An interrupted resolution is not + * cached, so the next read resolves again. */ export const makeCachedProviderMaintenanceResolution = Effect.fn( "makeCachedProviderMaintenanceResolution", )(function* (resolve: Effect.Effect) { - const [cached, invalidate] = yield* Effect.cachedInvalidateWithTTL( - resolve, - MAINTENANCE_CAPABILITIES_CACHE_TTL, - ); + const cache = yield* Cache.make({ + capacity: 1, + timeToLive: MAINTENANCE_CAPABILITIES_CACHE_TTL, + lookup: () => resolve, + }); + const cached = Cache.get(cache, undefined); return (options?: { readonly fresh?: boolean }) => - options?.fresh ? invalidate.pipe(Effect.andThen(cached)) : cached; + options?.fresh ? Cache.invalidate(cache, undefined).pipe(Effect.andThen(cached)) : cached; }); function deriveVersionAdvisory(input: {