diff --git a/docs/internal/EVENTS.md b/docs/internal/EVENTS.md index e0fead921..f4b3eabb9 100644 --- a/docs/internal/EVENTS.md +++ b/docs/internal/EVENTS.md @@ -84,7 +84,7 @@ worker is ever contacted. `lease-id-taken` (ADR 0020; widens a published vocabul | `device.quarantine-recovered` | device id, attempts, reclaim strategy (erase/snapshot/wipe/uninstall), `missingApps` for a physical device (additive), the device's `platform` (`ios` or `android`) | a quarantined device's retried purge succeeded; it returned to `ready`/`shutdown` and rejoined the warm pool. Never emitted for a spent `fresh` device (`mayBeGranted` false): its retry is a delete, and a successful one emits `device.deleted` with initiator `lease-end` | QuarantineCoordinator | implemented | | `device.quarantine-abandoned` | device id, attempts, the device's `platform` (`ios` or `android`) | a quarantined device exhausted its configured retry budget (`warmPool.quarantine.maxRetries`) — purge retries, or delete retries for a spent `fresh` device — and was destroyed, or, for a physical device, unenrolled: Simlock never destroys one (safety rule 2, ADR 0022 §4) | QuarantineCoordinator | implemented | | `device.quarantine-stranded` | device id, attempts, stable error summary, the device's `platform` (`ios` or `android`) | a quarantined device exhausted its retry budget (purge or delete retries) and the destroy that should have retired it also failed; it stays `quarantined` with no further retry until an operator intervenes | QuarantineCoordinator | implemented | -| `device.shutdown` | device id, initiator (rule/command; `warm-pool` when the warm pool shut an idle device down, over the running limit, with `warmPool.enabled: false`, to keep `warmPool.reserveRunning` running slots free, or because a ready device that never served a lease and that no `warmPool.targets` entry and no kind the pool learned from a lease keeps has been idle past `idle.shutdownAfterMs`, or to displace it: an older leased kind's idle device shut down so a newer kind can boot into its running slot) | device stopped, still on disk | Registry; ReclaimCoordinator for interrupted reclaim recovery | implemented | +| `device.shutdown` | device id, initiator (rule/command; `warm-pool` when the warm pool shut an idle device down, over the running limit, with `warmPool.enabled: false`, to keep `warmPool.reserveRunning` running slots free, or because a ready device that never served a lease and that no `warmPool.targets` entry and no kind the pool learned from a lease keeps has been idle past `idle.shutdownAfterMs`, or to displace it: an idle device shut down so a newer learned kind, or a configured target, can boot into its slot) | device stopped, still on disk | Registry; ReclaimCoordinator for interrupted reclaim recovery | implemented | | `device.deleted` | device id, initiator (`lease-end` when a `fresh` device's lease ended and its delete completed — from ReclaimCoordinator, at startup convergence, or retried from quarantine; #75. `doctor` when `doctor --fix` marked a missing device, or startup's lease reconciler found a leased device absent from a platform it could read and ended the lease in the same write, ADR 0019 §2. `operator` when `device remove` unenrolled a physical device, ADR 0022 §2. `quarantine` when a physical device's quarantine retries ran out and it was unenrolled, ADR 0022 §4; `lease-engine` when a device that failed to boot for a lease was destroyed in cleanup; `cleanup-reaper` when the cleanup reaper destroyed a device; `nuke` when `nuke` destroyed it), physical (the record's own flag: `true` for an enrolled physical device, `false` for a simulator or emulator; additive, events rule 6) | device removed from the registry, and from disk for a virtual device; a physical device is only forgotten and nothing is sent to it | Registry | implemented | | `device.enrolled` | device id, platform, driver device id (the canonical ID), model, OS version, class (phone/tablet), app count, reenrolled | an admin's `device add` wrote a physical device `ready` (ADR 0022 §2). `reenrolled` is `true` when the device already had a `ready`, `quarantined` or `absent` record, which kept its registry ID and took a fresh read of model, OS version, class and apps | Registry | implemented | | `device.absence-detected` | device id, previous state (ready/leased/reclaiming), absent reason (locked/untrusted/not-present) | an enrolled physical device became `absent` (ADR 0022 §2, §7, §8): the `PhysicalDeviceWatcher` read it not present `health.stableObservations` times in a row (a leased device's lease ends first, `lease.released` device-lost, in the same write), or `ReclaimCoordinator` found it not present after a failed reclaim (`reclaiming → absent`, no `device.purge-failed`, no `device.quarantined`). Emitted after the commit | Registry (the watcher's writes, the reclaim's failure path) | implemented | diff --git a/docs/internal/adr/0038-the-warm-pool-learns-its-kinds-from-leases-and-boots-for-every-queued-request.md b/docs/internal/adr/0038-the-warm-pool-learns-its-kinds-from-leases-and-boots-for-every-queued-request.md index ef584e8f7..b4a0f9029 100644 --- a/docs/internal/adr/0038-the-warm-pool-learns-its-kinds-from-leases-and-boots-for-every-queued-request.md +++ b/docs/internal/adr/0038-the-warm-pool-learns-its-kinds-from-leases-and-boots-for-every-queued-request.md @@ -91,14 +91,17 @@ is ready devices only (ADR 0017 §5). The budget is ADR 0017 §4, unchanged: the running limit minus the reserve minus held slots, the device limit, the RAM budget and the boot cap. Within it the pass fills, in this order: configured targets in config order, then -learned kinds by their last lease, newest first. +learned kinds by their last lease, newest first. Every configured count is +filled before any learned count, a configured kind's learned peak included. A learned kind short for a running slot may take the slot of an idle ready device of an older learned kind, when that device is kept by no configured target and serves no waiting request: the device is shut down (initiator `warm-pool`, reason `displaced`), least recently used first, and the newer -kind boots or creates into the slot. A configured target's devices are never -displaced, and a configured target never displaces (as today). A kind both +kind boots or creates into the slot. A configured count short for a running +or reserved slot takes the slot of an idle ready device no configured count +keeps: one of a learned kind, or a copy past its own kind's configured +count. A device a configured count keeps is never displaced. A kind both configured and learned is one entry, origin `configured`, count the larger of the two: the entry needs the larger of what the configured count lacks in ready devices and what the learned count lacks in devices had. diff --git a/src/agent-device/AGENTS.md b/src/agent-device/AGENTS.md index f9cac14e6..43363dec4 100644 --- a/src/agent-device/AGENTS.md +++ b/src/agent-device/AGENTS.md @@ -104,11 +104,11 @@ under leasing's signal (`lease.endWaitMs`). ## Flow: through a gateway 1. Gateway front runs the same rules; its link is `RelayHostLink`. -2. Allocate's `lease.request` adds `requires: ["agent-device"]`; the gateway places it on - a worker whose capability is available. +2. Allocate's `lease.request` adds `requires: ["agent-device"]`; the gateway places it on a worker whose capability is available. 3. Every later call is relayed to the lease's worker (`FleetPort.relay` → `GatewayRelay`, target `agent-device`) with tenant `gw..`, cut to 128 - chars with a hash. A lease not the token's → 403. No lease: only `GET /health`. + chars with a hash. A lease not the token's → 403. No lease: only `GET /health`, to the first undrained capable worker. + Relayed calls drop the client's `x-agent-device-(upstream-)instance` pins: they name the `/health` daemon, not the lease's (409). 4. Worker: `agentDeviceRelayTarget` → `RoutedHostLink` (the lease id picks the phone daemon or the simulator daemon) → host, requester = tenant. No front rules run on the worker. 5. Worker unreachable → "try again" reply; a command so answered ends its renewal diff --git a/src/agent-device/front/relay-host-link.test.ts b/src/agent-device/front/relay-host-link.test.ts index e4634fff8..6e1656d14 100644 --- a/src/agent-device/front/relay-host-link.test.ts +++ b/src/agent-device/front/relay-host-link.test.ts @@ -92,6 +92,30 @@ describe("RelayHostLink", () => { expect(new Set(tenants).size).toBe(principals.length); }); + it.each([ + { + leaseId: undefined, + row: "a health probe", + sent: () => new Request("http://gateway.test/v1/agent-device/health"), + }, + { leaseId: "L", row: "a call on a lease", sent: () => request() }, + ])( + "relays $row without the client's agent-device instance pins, which name the daemon it read /health from", + async ({ leaseId, sent: make }) => { + const { index, link, relay } = setUp(); + index.set("L", { ownerId: "tok_a", workerId: "w1" }); + const sent = make(); + sent.headers.set("x-agent-device-instance", "proxy-of-another-worker"); + sent.headers.set("x-agent-device-upstream-instance", "daemon-of-another-worker"); + + await link.forward(sent, agent, leaseId); + + const relayed = relay.mock.calls[0]?.[1]; + expect(relayed?.headers.has("x-agent-device-instance")).toBe(false); + expect(relayed?.headers.has("x-agent-device-upstream-instance")).toBe(false); + }, + ); + it("answers the flat refusal shape for a request that is not an rpc call", async () => { const { link } = setUp(); diff --git a/src/agent-device/front/relay-host-link.ts b/src/agent-device/front/relay-host-link.ts index 991c5d87b..ed6145d0b 100644 --- a/src/agent-device/front/relay-host-link.ts +++ b/src/agent-device/front/relay-host-link.ts @@ -138,6 +138,9 @@ export class RelayHostLink implements HostLink { } async forward(request: Request, identity: Identity, leaseId?: string): Promise { + // why: a client pins the daemon it read `/health` from, which with several workers is often not the lease's worker's daemon, and that daemon refuses every pinned call as an instance mismatch (409). + request.headers.delete("x-agent-device-instance"); + request.headers.delete("x-agent-device-upstream-instance"); const tenant = relayTenant(this.options.fleet.instanceId, identity.principal); if (leaseId === undefined) return this.#forwardBare(request, tenant); const placed = this.#place(leaseId); diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index 0f2431a2d..3def1d7ce 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -944,6 +944,7 @@ export const configSchema = z.object({ update: z.object({ check: z.boolean() }), warmPool: z.object({ enabled: z.boolean(), + learn: z.boolean().optional(), reserveRunning: z.object({ ios: z.number(), android: z.number() }), maxConcurrentBoots: z.number(), targets: z.array( diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 48e9f50f5..3ea5d81c7 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -123,6 +123,7 @@ describe("loadConfig", () => { http: { enabled: true, host: "127.0.0.1", port: 4701 }, warmPool: { enabled: true, + learn: true, maxConcurrentBoots: 2, reserveRunning: { android: 0, ios: 0 }, targets: [], diff --git a/src/core/warm-pool/config.ts b/src/core/warm-pool/config.ts index de8eef7a2..b7c4cdb49 100644 --- a/src/core/warm-pool/config.ts +++ b/src/core/warm-pool/config.ts @@ -35,6 +35,8 @@ export interface WarmPoolConfig { readonly reserveRunning: { readonly ios: number; readonly android: number }; /** Kinds of device kept booted ahead of demand. */ readonly targets: readonly WarmTarget[]; + /** `false` keeps only `targets` warm, not the kinds the pool learns from leases. */ + readonly learn?: boolean; /** why: the most boots and creations in flight at once: the pool's own, for waiters and targets, and each waiting request's own. */ readonly maxConcurrentBoots: number; } @@ -42,6 +44,7 @@ export interface WarmPoolConfig { export function defaultWarmPoolConfig(systemStats: SystemStats): WarmPoolConfig { return { enabled: true, + learn: true, maxConcurrentBoots: Math.max(1, Math.floor(systemStats.cpuCount() / 4)), reserveRunning: { android: 0, ios: 0 }, targets: [], @@ -107,6 +110,7 @@ const targetsValidator: Validator = (value, path, warn) => { /** The validators for the keys this module owns, composed into the `warmPool` object. */ export const warmPoolConfigValidator: Readonly> = { enabled: booleanValue, + learn: booleanValue, maxConcurrentBoots: positiveInteger, reserveRunning: objectValidator({ android: nonNegativeInteger, ios: nonNegativeInteger }), targets: targetsValidator, diff --git a/src/core/warm-pool/converger.test.ts b/src/core/warm-pool/converger.test.ts index 6d4cf8970..cebb0e510 100644 --- a/src/core/warm-pool/converger.test.ts +++ b/src/core/warm-pool/converger.test.ts @@ -107,6 +107,7 @@ function harness( options: { limit?: number; enabled?: boolean; + learn?: boolean; refuseBoot?: boolean; shutdown?: (target: VirtualDevice) => Promise; boot?: (target: VirtualDevice) => Promise; @@ -262,6 +263,7 @@ function harness( clock, config: { enabled: options.enabled ?? true, + ...(options.learn === undefined ? {} : { learn: options.learn }), maxConcurrentBoots: options.maxConcurrentBoots ?? 1, reserveRunning: { android: 0, ios: 0 }, targets: options.targets ?? [], @@ -2609,6 +2611,12 @@ describe("warm pool learned kinds", () => { expect(off.pool.figures().targets.map((target) => [target.model, target.origin])).toEqual([ ["iPhone 15", "configured"], ]); + const unlearned = harness([], { learn: false, state, targets: [phone] }); + await unlearned.pool.pass(); + await unlearned.pool.settle(); + expect( + unlearned.pool.figures().targets.map((target) => [target.model, target.origin]), + ).toEqual([["iPhone 15", "configured"]]); const fresh = harness([], { state, targets: [phone] }); expect(fresh.pool.figures().targets.map((target) => [target.model, target.origin])).toEqual([ ["iPhone 15", "configured"], diff --git a/src/core/warm-pool/converger.ts b/src/core/warm-pool/converger.ts index 3ff75251d..67314fef8 100644 --- a/src/core/warm-pool/converger.ts +++ b/src/core/warm-pool/converger.ts @@ -226,6 +226,7 @@ export class WarmPool { /** why: the learned kinds, or none when the record cannot be read; the failure is logged once per pass. */ #learnedKinds(): LearnedKind[] { + if (this.options.config.learn === false) return []; try { return this.#learned.kinds(this.options.clock.now()); } catch (error: unknown) { diff --git a/src/core/warm-pool/policy.test.ts b/src/core/warm-pool/policy.test.ts index 189156f5c..222a8ccc3 100644 --- a/src/core/warm-pool/policy.test.ts +++ b/src/core/warm-pool/policy.test.ts @@ -1202,13 +1202,40 @@ describe("warm pool policy", () => { expect(queued.targets[0]).not.toHaveProperty("short"); }); - it("never shuts a device down to make room for a target", () => { + it("shuts down an idle device no configured count keeps to make room for a target, and never one a configured count keeps", () => { const idle = device("idle", "ready", { endedAgo: minute, spec: spec("iPad Pro") }); + const make = { action: "provision", reason: "target", spec: kind, target: kind }; const result = plan(view([idle], { limit: 1, targets: [target(1)] })); - expect(result.proposals).toEqual([]); - expect(result.targets[0]).toMatchObject({ short: "running-limit" }); + expect(result.proposals).toEqual([ + { action: "shutdown", deviceId: "idle", reason: "displaced" }, + make, + ]); + expect(result.targets[0]).toMatchObject({ booting: 1, ready: 0 }); + const pad = { count: 1, spec: spec("iPad Pro") }; + expect(evaluate(view([idle], { limit: 1, targets: [target(1), pad] }))).toEqual([]); + expect(evaluate(view([idle], { limit: 1, targets: [pad, target(1)] }))).toEqual([]); + }); + + it("shuts down a copy past another configured kind's count, for a slot the reserve holds too, and fills every configured count before a learned peak", () => { + const pad = spec("iPad Pro"); + const pads = ["a", "b"].map((id, index) => + device(id, "ready", { endedAgo: (index + 1) * minute, spec: pad }), + ); + const both = [{ count: 1, spec: pad }, target(1)]; + const make = { action: "provision", reason: "target", spec: kind, target: kind }; + + expect(evaluate(view(pads, { limit: 2, targets: both }))).toEqual([ + { action: "shutdown", deviceId: "b", reason: "displaced" }, + make, + ]); + expect( + evaluate(view(pads, { limits: { ios: 3 }, reserve: { ios: 1 }, targets: both })), + ).toEqual([{ action: "shutdown", deviceId: "b", reason: "displaced" }, make]); + expect( + evaluate(view([pads[0]!], { learned: [learnedOf(pad, 2, now)], limit: 2, targets: both })), + ).toEqual([make]); }); it("counts two targets resolving to one spec as one with the summed count", () => { @@ -1760,7 +1787,7 @@ describe("warm pool policy", () => { ).toEqual([provision(kind)]); }); - it("displaces the least recently used idle ready device of an older learned kind for a newer one short for a running slot, and never a configured target's device, one that serves a waiting request or is already shut down for the budget, for a kind short for ram-budget, device-limit or reserve, or for a configured target", () => { + it("displaces the least recently used idle ready device of an older learned kind for a newer one short for a running slot, and never a configured target's device, one that serves a waiting request or is already shut down for the budget, or for a kind short for ram-budget, device-limit or reserve; a configured target displaces it", () => { const learned = [learnedOf(kind, 1, now - minute), learnedOf(older, 2, now - 2 * minute)]; const recent = device("recent", "ready", { endedAgo: 5 * minute, spec: older }); const stale = device("stale", "ready", { endedAgo: 30 * minute, spec: older }); @@ -1828,16 +1855,17 @@ describe("warm pool policy", () => { }), ), ).toEqual([]); + const phone = spec("iPhone 15"); expect( evaluate( view([recent], { learned: [learnedOf(older, 1, now)], limit: 1, maxConcurrentBoots: 2, - targets: [{ count: 1, spec: spec("iPhone 15") }], + targets: [{ count: 1, spec: phone }], }), ), - ).toEqual([]); + ).toEqual([{ ...displace, deviceId: "recent" }, provision(phone)]); }); }); }); diff --git a/src/core/warm-pool/policy.ts b/src/core/warm-pool/policy.ts index decce40de..ab4948abc 100644 --- a/src/core/warm-pool/policy.ts +++ b/src/core/warm-pool/policy.ts @@ -604,6 +604,8 @@ interface TargetPlan { /** why: the idle ready devices a newer learned kind may shut down for a slot, least recently used first. */ readonly victims: VirtualDevice[]; readonly displaced: Set; + /** Idle and not displaced this pass: the devices a kind still counts as its own. */ + readonly isIdle: (device: VirtualDevice) => boolean; /** Pool boots and creations running or proposed, which `maxConcurrentBoots` bounds. */ started: number; } @@ -635,6 +637,7 @@ function planTargets( bootable, displaced, held, + isIdle, kinds: targets, proposals: [], room, @@ -654,25 +657,40 @@ function planTargets( (device) => device.id === proposal.deviceId && sameSpec(device.spec, spec), ), ).length; - const reports = targets.map(({ configuredCount, learnedCount, origin, spec }): TargetReport => { - const ready = readyOfKind(view.devices, isIdle, spec).length; + // why: every configured count is filled before any learned count, so a learned kind, or a + // configured kind's learned peak, only takes the slots the configured counts leave. + const first = targets.map(({ configuredCount, spec }) => { const filling = pending(spec); - const missing = - Math.max(configuredCount - ready, learnedCount - haveOfKind(view, isIdle, spec)) - filling; - const before = plan.proposals.length; - const short = missing > 0 ? fillTarget(plan, spec, missing, filling > 0) : undefined; - const report = { - booting: filling + plan.proposals.length - before, - configuredCount, - count: Math.max(configuredCount, learnedCount), - kind: kindOf(spec), - origin, - ready, - spec, - target: describeTarget(spec), - }; - return short === undefined ? report : { ...report, short }; + const missing = configuredCount - readyOfKind(view.devices, isIdle, spec).length - filling; + return missing > 0 ? fillTarget(plan, spec, missing, filling > 0, "configured") : { added: 0 }; }); + const reports = targets.map( + ({ configuredCount, learnedCount, origin, spec }, index): TargetReport => { + const ready = readyOfKind(view.devices, isIdle, spec).length; + const filling = pending(spec); + const configured = first[index] ?? { added: 0 }; + const missing = + Math.max(configuredCount - ready, learnedCount - haveOfKind(view, isIdle, spec)) - + filling - + configured.added; + const learned = + configured.short === undefined && missing > 0 + ? fillTarget(plan, spec, missing, filling + configured.added > 0, "learned") + : { added: 0 }; + const short = configured.short ?? learned.short; + const report = { + booting: filling + configured.added + learned.added, + configuredCount, + count: Math.max(configuredCount, learnedCount), + kind: kindOf(spec), + origin, + ready, + spec, + target: describeTarget(spec), + }; + return short === undefined ? report : { ...report, short }; + }, + ); return { proposals: plan.proposals, reports }; } @@ -686,31 +704,55 @@ function fillTarget( spec: VirtualSpec, missing: number, filling: boolean, -): TargetShort | undefined { - if (plan.stuck.has(spec.platform)) return undefined; - if (!plan.view.retry.mayAttempt(spec, plan.view.now)) return filling ? undefined : "boot-failed"; + count: "configured" | "learned", +): { added: number; short?: TargetShort } { + if (plan.stuck.has(spec.platform)) return { added: 0 }; + if (!plan.view.retry.mayAttempt(spec, plan.view.now)) { + return filling ? { added: 0 } : { added: 0, short: "boot-failed" }; + } let added = 0; for (let left = missing; left > 0 && plan.started < plan.view.config.maxConcurrentBoots; left--) { - const refused = addDisplacing(plan, spec); - if (refused !== undefined) return added > 0 || filling ? undefined : refused; + const refused = addDisplacing(plan, spec, count); + if (refused !== undefined) return added > 0 || filling ? { added } : { added, short: refused }; added += 1; } - return undefined; + return { added }; } /** * why: a learned kind short for a running slot takes the least recently used idle ready device of - * an older learned kind: the newest kinds are the ones asked for most lately. A configured target - * and a kind short for anything but the slot never displace; configured targets come first in - * `kinds`, so none is older than a learned kind and none is displaced. + * an older learned kind: the newest kinds are the ones asked for most lately. Configured targets + * come first in `kinds`, so none is older than a learned kind and none is displaced for one. */ -function displaceFor(plan: TargetPlan, spec: VirtualSpec): boolean { +function learnedVictim(plan: TargetPlan, spec: VirtualSpec): VirtualDevice | undefined { const index = plan.kinds.findIndex((entry) => sameSpec(entry.spec, spec)); - if (plan.kinds[index]?.origin !== "learned") return false; - const victim = plan.victims.find((device) => { + if (plan.kinds[index]?.origin !== "learned") return undefined; + return plan.victims.find((device) => { const entry = plan.kinds.findIndex((candidate) => sameSpec(candidate.spec, device.spec)); return entry > index && device.spec.platform === spec.platform; }); +} + +/** + * why: a configured count short for a slot takes the least recently used idle ready device that + * no configured count keeps: one of a learned kind, or a copy past its own kind's configured count. + */ +function configuredVictim(plan: TargetPlan, spec: VirtualSpec): VirtualDevice | undefined { + return plan.victims.find((device) => { + if (device.spec.platform !== spec.platform || sameSpec(device.spec, spec)) return false; + const kept = + plan.kinds.find((entry) => sameSpec(entry.spec, device.spec))?.configuredCount ?? 0; + return readyOfKind(plan.view.devices, plan.isIdle, device.spec).length > kept; + }); +} + +/** why: shuts down the victim for `spec`'s slot, when there is one and its slot is what `spec` lacks. */ +function displaceFor( + plan: TargetPlan, + spec: VirtualSpec, + count: "configured" | "learned", +): boolean { + const victim = count === "configured" ? configuredVictim(plan, spec) : learnedVictim(plan, spec); if (victim === undefined) return false; // why: a slot is not all the kind lacks; when the capacity strategy still refuses it with the victim down, the shutdown would free nothing the kind can use and the victim would boot back next pass. if (admitted(plan, spec, new Set([...plan.displaced, victim.id])) !== undefined) return false; @@ -721,10 +763,19 @@ function displaceFor(plan: TargetPlan, spec: VirtualSpec): boolean { return true; } -/** why: a learned kind refused for a running slot tries again after each displacement it can make. */ -function addDisplacing(plan: TargetPlan, spec: VirtualSpec): TargetShort | undefined { +/** + * why: a learned count refused for a running slot, or a configured count refused for a running or + * reserved slot, tries again after each displacement it can make. + */ +function addDisplacing( + plan: TargetPlan, + spec: VirtualSpec, + count: "configured" | "learned", +): TargetShort | undefined { + const slot = (refused: TargetShort | undefined): boolean => + refused === "running-limit" || (count === "configured" && refused === "reserve"); let refused = addOne(plan, spec); - while (refused === "running-limit" && displaceFor(plan, spec)) refused = addOne(plan, spec); + while (slot(refused) && displaceFor(plan, spec, count)) refused = addOne(plan, spec); return refused; } diff --git a/src/drivers/android/AGENTS.md b/src/drivers/android/AGENTS.md index 47a271484..bbf865c67 100644 --- a/src/drivers/android/AGENTS.md +++ b/src/drivers/android/AGENTS.md @@ -19,7 +19,7 @@ Parent: [drivers](../AGENTS.md). Flows: [device selection](../../../docs/interna | `default-adb-server.ts` | `DefaultAdbServerGate`: protocol check of the server on 5037 | | `physical-handler.ts` | `AndroidPhysicalHandler`: enrol, presence, reclaim, passthrough for phones | | `device-profile-source.ts` | `DeviceProfileRegistry`; SDK and devices.xml profile sources | -| `profiles/index.ts` | `LibraryDeviceProfileSource`: Simlock's 16 profiles | +| `profiles/index.ts` | `LibraryDeviceProfileSource`: Simlock's 17 profiles | | `image-fit.ts` | `fits`, `misfitReason`: which system image a profile takes | | `sdk-paths.ts` | `locateSdk`: SDK root and tool paths, shared with `prerequisites.ts` | | `data.ts` | `AndroidDriverData`: `avdName`, `configHash`, `imageIdentity`, `port`, `serial` | diff --git a/src/drivers/android/index.test.ts b/src/drivers/android/index.test.ts index 7773e076e..e201d0376 100644 --- a/src/drivers/android/index.test.ts +++ b/src/drivers/android/index.test.ts @@ -1327,6 +1327,7 @@ describe("AndroidDriver", () => { "Pixel 5": ["34", "35"], "Pixel 9": ["35"], "Pixel 9 Pro XL": ["35"], + "Galaxy Z Fold 8": [], }), models: [...libraryModels, "Pixel 5"], runtimes: ["34", "35"], @@ -1355,6 +1356,7 @@ describe("AndroidDriver", () => { "Pixel 8": ["35"], "Pixel 9": ["35"], "Pixel 9 Pro XL": ["35"], + "Galaxy Z Fold 8": [], }), ); }); diff --git a/src/drivers/android/profiles/galaxy-z-fold-8.ts b/src/drivers/android/profiles/galaxy-z-fold-8.ts new file mode 100644 index 000000000..db5850140 --- /dev/null +++ b/src/drivers/android/profiles/galaxy-z-fold-8.ts @@ -0,0 +1,132 @@ +import type { LibraryDeviceProfile } from "./index.js"; + +// why: the SDK ships no Samsung profiles. This is the SDK's pixel_fold (a passport-shaped fold +// that opens landscape) with the Galaxy Z Fold 8's screens: inner 7.6" 2448x1848 (4:3), cover +// 5.5" 1248x1972 (16:10), hinge down the middle of the inner panel. It keeps pixel_fold's hinge +// and posture keys, which LIBRARY_HARDWARE_KEYS does not list, and its hw.device.name: the +// emulator configures the cover display and the CLOSED/HALF_OPENED/OPENED states only for a +// device it knows as a fold, and boots any other name as one flat 2448x1848 screen. +export const GALAXY_Z_FOLD_8: LibraryDeviceProfile = { + kind: "library", + name: "Galaxy Z Fold 8", + id: "galaxy_z_fold_8", + names: ["Galaxy Z Fold 8", "galaxy_z_fold_8"], + deviceClass: "phone", + imageKind: undefined, + apiRange: { min: 36 }, + playStore: true, + tablet: false, + hardware: { + "hw.accelerometer": "yes", + "hw.accelerometer_uncalibrated": "yes", + "hw.arc": "false", + "hw.arc.autologin": "no", + "hw.audioInput": "yes", + "hw.audioOutput": "yes", + "hw.battery": "yes", + "hw.camera.back": "emulated", + "hw.camera.back.orientation": "90", + "hw.camera.front": "none", + "hw.camera.front.orientation": "90", + "hw.cpu.ncore": "4", + "hw.dPad": "no", + "hw.device.manufacturer": "Samsung", + "hw.device.name": "pixel_fold", + "hw.display1.density": "0", + "hw.display1.flag": "0", + "hw.display1.height": "0", + "hw.display1.width": "0", + "hw.display1.xOffset": "-1", + "hw.display1.yOffset": "-1", + "hw.display2.density": "0", + "hw.display2.flag": "0", + "hw.display2.height": "0", + "hw.display2.width": "0", + "hw.display2.xOffset": "-1", + "hw.display2.yOffset": "-1", + "hw.display3.density": "0", + "hw.display3.flag": "0", + "hw.display3.height": "0", + "hw.display3.width": "0", + "hw.display3.xOffset": "-1", + "hw.display3.yOffset": "-1", + "hw.displayRegion.0.1.height": "1972", + "hw.displayRegion.0.1.width": "1248", + "hw.displayRegion.0.1.xOffset": "0", + "hw.displayRegion.0.1.yOffset": "0", + "hw.displayRegion.0.2.height": "0", + "hw.displayRegion.0.2.width": "0", + "hw.displayRegion.0.2.xOffset": "-1", + "hw.displayRegion.0.2.yOffset": "-1", + "hw.displayRegion.0.3.height": "0", + "hw.displayRegion.0.3.width": "0", + "hw.displayRegion.0.3.xOffset": "-1", + "hw.displayRegion.0.3.yOffset": "-1", + "hw.gltransport": "pipe", + "hw.gltransport.asg.dataRingSize": "32768", + "hw.gltransport.asg.writeBufferSize": "1048576", + "hw.gltransport.asg.writeStepSize": "4096", + "hw.gltransport.drawFlushInterval": "800", + "hw.gps": "yes", + "hw.gpu.enabled": "no", + "hw.gpu.mode": "auto", + "hw.gsmModem": "yes", + "hw.gyroscope": "yes", + "hw.hotplug_multi_display": "no", + "hw.initialOrientation": "portrait", + "hw.keyboard": "no", + "hw.keyboard.charmap": "qwerty2", + "hw.keyboard.lid": "yes", + "hw.lcd.backlight": "yes", + "hw.lcd.circular": "false", + "hw.lcd.density": "420", + "hw.lcd.depth": "32", + "hw.lcd.height": "1848", + "hw.lcd.transparent": "false", + "hw.lcd.vsync": "60", + "hw.lcd.width": "2448", + "hw.mainKeys": "no", + "hw.multi_display_window": "no", + "hw.ramSize": "2G", + "hw.rotaryInput": "no", + "hw.screen": "multi-touch", + "hw.sdCard": "yes", + "hw.sensor.hinge": "yes", + "hw.sensor.hinge.areas": "1224-0-0-1848", + "hw.sensor.hinge.count": "1", + "hw.sensor.hinge.defaults": "180", + "hw.sensor.hinge.fold_to_displayRegion.0.1_at_posture": "1", + "hw.sensor.hinge.ranges": "0-180", + "hw.sensor.hinge.resizable.config": "1", + "hw.sensor.hinge.sub_type": "1", + "hw.sensor.hinge.type": "1", + "hw.sensor.hinge_angles_posture_definitions": "0-30, 30-150, 150-180", + "hw.sensor.posture_list": "1, 2, 3", + "hw.sensor.roll": "no", + "hw.sensor.roll.count": "0", + "hw.sensor.roll.resize_to_displayRegion.0.1_at_posture": "6", + "hw.sensor.roll.resize_to_displayRegion.0.2_at_posture": "6", + "hw.sensor.roll.resize_to_displayRegion.0.3_at_posture": "6", + "hw.sensors.gyroscope_uncalibrated": "yes", + "hw.sensors.heading": "no", + "hw.sensors.heart_rate": "no", + "hw.sensors.humidity": "yes", + "hw.sensors.light": "yes", + "hw.sensors.magnetic_field": "yes", + "hw.sensors.magnetic_field_uncalibrated": "yes", + "hw.sensors.orientation": "yes", + "hw.sensors.pressure": "yes", + "hw.sensors.proximity": "yes", + "hw.sensors.rgbclight": "no", + "hw.sensors.temperature": "yes", + "hw.sensors.wrist_tilt": "no", + "hw.touchpad0": "no", + "hw.touchpad0.height": "400", + "hw.touchpad0.width": "600", + "hw.trackBall": "no", + "hw.useext4": "yes", + "vm.heapSize": "228M", + }, + bootProperties: {}, + copiedFrom: "20.0", +}; diff --git a/src/drivers/android/profiles/index.ts b/src/drivers/android/profiles/index.ts index 1285ef57e..b545e0ac8 100644 --- a/src/drivers/android/profiles/index.ts +++ b/src/drivers/android/profiles/index.ts @@ -3,6 +3,7 @@ import { AUTOMOTIVE_1024P_LANDSCAPE } from "./automotive-1024p-landscape.js"; import { AUTOMOTIVE_1080P_LANDSCAPE } from "./automotive-1080p-landscape.js"; import { DESKTOP_LARGE } from "./desktop-large.js"; import { DESKTOP_MEDIUM } from "./desktop-medium.js"; +import { GALAXY_Z_FOLD_8 } from "./galaxy-z-fold-8.js"; import { MEDIUM_PHONE } from "./medium-phone.js"; import { MEDIUM_TABLET } from "./medium-tablet.js"; import { PIXEL_6 } from "./pixel-6.js"; @@ -35,6 +36,7 @@ export const LIBRARY_PROFILES: readonly LibraryDeviceProfile[] = [ AUTOMOTIVE_1024P_LANDSCAPE, DESKTOP_MEDIUM, DESKTOP_LARGE, + GALAXY_Z_FOLD_8, ]; export class LibraryDeviceProfileSource implements DeviceProfileSource { diff --git a/src/drivers/android/profiles/library.test.ts b/src/drivers/android/profiles/library.test.ts index 52a103348..b8eea2a90 100644 --- a/src/drivers/android/profiles/library.test.ts +++ b/src/drivers/android/profiles/library.test.ts @@ -90,6 +90,7 @@ const TABLE = [ ], ["desktop_medium", "Medium Desktop", "desktop", "android-desktop", 32, false, {}], ["desktop_large", "Large Desktop", "desktop", "android-desktop", 32, false, {}], + ["galaxy_z_fold_8", "Galaxy Z Fold 8", "phone", undefined, 36, true, {}], ] as const; function byId(id: string) { @@ -126,7 +127,7 @@ describe("the Android device profile library", () => { expect(profile.bootProperties).toEqual({}); }); - it("the library holds exactly the 16 profiles #485 lists, by name", () => { + it("the library holds exactly the 16 profiles #485 lists and the Galaxy Z Fold 8, by name", () => { expect(LIBRARY_PROFILES.map((profile) => profile.name).sort()).toEqual( ["Pixel 9", ...TABLE.map((row) => row[1])].sort(), ); diff --git a/website/src/content/docs/docs/reference/configuration.mdx b/website/src/content/docs/docs/reference/configuration.mdx index 650a1b302..806760a5f 100644 --- a/website/src/content/docs/docs/reference/configuration.mdx +++ b/website/src/content/docs/docs/reference/configuration.mdx @@ -116,6 +116,7 @@ you set. If you raise `ios.maxDevices`, raise `ios.maxRunning` and `limits.maxRu | Key | Type | Default | Meaning | Applies to | | -------------------------------------------- | ----------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------- | | `warmPool.enabled` | boolean | `true` | Keep released devices booted, and keep ready as many of each kind leased in the last 24 hours as were leased at once. `false` keeps nothing warm and shuts every idle running device down. | Worker | +| `warmPool.learn` | boolean | `true` | Keep the kinds leased in the last 24 hours warm, beside `targets`. `false` keeps only `targets` warm. | Worker | | `warmPool.targets` | list of targets (below) | `[]` | Kinds of device kept booted, clean and unleased ahead of demand. At most 256 entries. | Worker | | `warmPool.reserveRunning.ios` | non-negative integer | `0` | iOS running slots idle warm devices may not fill, so an unexpected request boots at once. | Worker | | `warmPool.reserveRunning.android` | non-negative integer | `0` | The same for Android. | Worker |