diff --git a/apps/server/src/device/SimlockDeviceHost.test.ts b/apps/server/src/device/SimlockDeviceHost.test.ts index 752a63c038b81..8726a7a7c03de 100644 --- a/apps/server/src/device/SimlockDeviceHost.test.ts +++ b/apps/server/src/device/SimlockDeviceHost.test.ts @@ -4,6 +4,7 @@ import { SecretProviderId, type SimlockDeviceHostConfig } from "@t3tools/contrac import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; import * as Exit from "effect/Exit"; @@ -56,6 +57,8 @@ const fakePool = ( readonly entered: Deferred.Deferred; readonly proceed: Deferred.Deferred; }; + /** The gateway's `lease.maxTtlMs`: leases expire one TTL after their last renewal. */ + readonly maxTtlMs?: number; } = {}, ) => Effect.gen(function* () { @@ -66,8 +69,10 @@ const fakePool = ( let leases = 0; let renewStatus = 200; let catalog: unknown = CATALOG; + let leaseDeadline = Infinity; const http = HttpClient.make((request) => - Effect.sync(() => { + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; const url = new URL(request.url); requests.push(`${request.method} ${url.pathname}`); const json = (body: unknown, status = 200) => @@ -75,6 +80,17 @@ const fakePool = ( if (request.headers.authorization !== "Bearer agent-token") return json({ error: { code: "UNAUTHENTICATED", message: "Unknown bearer token" } }, 401); if (url.pathname === "/v1/catalog") return json(catalog); + if (url.pathname.endsWith("/renew") && options.maxTtlMs !== undefined) { + if (now >= leaseDeadline) return json({}, 404); + const body = + request.body._tag === "Uint8Array" + ? (JSON.parse(new TextDecoder().decode(request.body.body)) as { ttlMs?: number }) + : {}; + if ((body.ttlMs ?? 0) > options.maxTtlMs) + return json({ error: { code: "BAD_REQUEST", message: "ttlMs exceeds maxTtlMs" } }, 400); + leaseDeadline = now + (body.ttlMs ?? options.maxTtlMs); + return json({}); + } if (url.pathname.endsWith("/renew")) return json({}, renewStatus); if (request.method === "DELETE") return json({}, 202); return json({}, 404); @@ -114,6 +130,8 @@ const fakePool = ( path.join(dir, `${session}.json`), JSON.stringify({ leaseId: `lease-${++leases}` }), ); + if (options.maxTtlMs !== undefined) + leaseDeadline = (yield* Clock.currentTimeMillis) + options.maxTtlMs; } if (command === "screenshot") yield* fs.writeFile(args[1]!, new Uint8Array([1, 2, 3])); return { @@ -475,6 +493,21 @@ describe("Simlock device host", () => { }).pipe(Effect.scoped, Effect.provide(layer)), ); + it.effect("keeps a watched lease renewed when the gateway caps lease TTLs", () => + Effect.gen(function* () { + const pool = yield* fakePool({ maxTtlMs: 5 * 60_000 }); + const host = yield* makeHost(pool, config(), yield* Scope.make()); + const ready = yield* host.ensureReady(() => Effect.void); + const [iphone] = (yield* ready.devices.list).devices; + const leaseId = yield* ready.devices.boot(iphone!); + + yield* TestClock.adjust("6 minutes"); + expect((yield* ready.devices.list).devices.some((device) => device.id === leaseId)).toBe( + true, + ); + }).pipe(Effect.scoped, Effect.provide(layer)), + ); + it.effect("returns a lease whose open finishes after the host stopped", () => Effect.gen(function* () { const holdOpen = { diff --git a/apps/server/src/device/SimlockDeviceHost.ts b/apps/server/src/device/SimlockDeviceHost.ts index bab0558614dc2..417399c9114c8 100644 --- a/apps/server/src/device/SimlockDeviceHost.ts +++ b/apps/server/src/device/SimlockDeviceHost.ts @@ -42,9 +42,9 @@ const REQUEST_TIMEOUT = Duration.seconds(15); // The gateway queues a lease request for up to five minutes when every device is busy. const OPEN_TIMEOUT = Duration.minutes(7); const COMMAND_TIMEOUT = Duration.minutes(2); -// Renewal keeps a watched but idle device; a lease this server stops renewing ends after LEASE_TTL. +// Renewal keeps a watched but idle device. It sends no ttlMs: Simlock then keeps the lease's own +// length, while one above the gateway's lease.maxTtlMs is refused rather than shortened. const RENEW_INTERVAL = Duration.minutes(2); -const LEASE_TTL_MS = 10 * 60_000; const STREAM_PATH = /^\/vendor\/serve-sim\/helper\/([^/]+)\/stream\.avcc$/; const INPUT_PATH = "/vendor/serve-sim/helper/ws"; @@ -422,7 +422,6 @@ export const make = Effect.fn("SimlockDeviceHost.make")(function* ( lease.token, "POST", `/v1/leases/${encodeURIComponent(lease.leaseId)}/renew`, - { ttlMs: LEASE_TTL_MS }, ).pipe(Effect.result); if (renewed._tag === "Success") continue; const error = renewed.failure;