465 lines
26 KiB
Swift
465 lines
26 KiB
Swift
import Foundation
|
|
|
|
/// App-level coordinator for per-session sandbox containers (injected like
|
|
/// `GitWorktreeManager`). Owns the lifecycle the `ContainerEngine` is too low-level to
|
|
/// track: keeping one long-lived container per session, an idle timer that stops it after
|
|
/// inactivity, teardown on session end, and on-disk reconciliation on launch.
|
|
///
|
|
/// Activity is reference-counted: `ensureRunning` (called at the start of every turn)
|
|
/// marks the container busy and disarms the idle timer; `finished` marks the turn done and
|
|
/// re-arms it. The container is only stopped when no turn is in flight, so a single
|
|
/// long-running turn is never killed mid-flight.
|
|
public actor ContainerManager {
|
|
private let engine: ContainerEngine
|
|
|
|
/// Live per-container bookkeeping, keyed by **logical** container name (the stable name in this
|
|
/// type's public API — `containerName(for:)` / `sharedContainerName(for:split:)`).
|
|
private var active: [String: Int] = [:] // in-flight turn count
|
|
private var idleTimeouts: [String: TimeInterval] = [:]
|
|
private var idleTimers: [String: Task<Void, Never>] = [:]
|
|
|
|
/// Logical → **physical** container name for this run. The shared per-family control containers
|
|
/// run under a randomized physical name (a stable base + a random fun word, re-rolled each time
|
|
/// the sandbox is created) so an agent that glimpses another sandbox — via `ps`, a stray pid, a
|
|
/// host-exec — can neither recognize it as a sibling nor guess/target it by name. Bookkeeping and
|
|
/// every public method speak logical names; this map is applied only at the engine boundary.
|
|
/// Non-randomized names (the primary shared container, per-session containers) map to themselves.
|
|
/// Populated lazily on `ensureRunning`; an entry is dropped on teardown/recreate so the next
|
|
/// creation re-randomizes, and the whole map resets each app launch.
|
|
private var physicalNames: [String: String] = [:]
|
|
|
|
/// Invoked with a shared control container's **logical** name the moment that container is
|
|
/// *permanently* removed (``teardownShared()`` / ``recreateShared()``), so the owner can release
|
|
/// the container-scoped approval server and its control socket. Idle-stop / restart deliberately do
|
|
/// NOT fire it: the long-lived server is meant to survive those, and the reconnecting session's
|
|
/// `MCPApprovalServer.start(unixSocketPath:)` re-validates the socket, so tearing it down there
|
|
/// would be pure churn. Nil in tests / host-only setups.
|
|
private let onSharedContainerRemoved: (@Sendable (String) async -> Void)?
|
|
|
|
public init(
|
|
engine: ContainerEngine = ContainerEngine(),
|
|
onSharedContainerRemoved: (@Sendable (String) async -> Void)? = nil
|
|
) {
|
|
self.engine = engine
|
|
self.onSharedContainerRemoved = onSharedContainerRemoved
|
|
}
|
|
|
|
/// Per-channel suffix appended to every container name on non-release builds, so several
|
|
/// installed Nucleic builds (release + beta + local dev, etc.) running against the **shared**
|
|
/// on-disk container store (`ContainerEngine.defaultStorageRoot`) don't collide on their
|
|
/// per-container rootfs clones. Empty for the stable release; `-canary` / `-beta` / `-rc` /
|
|
/// `-local` for the non-release channels. Baked in at build time from the `NUCLEIC_CHANNEL` define
|
|
/// (mirrors NucleicApp's `BuildChannel`); NucleicCore is compiled with that define — see Package.swift.
|
|
public nonisolated static let channelSuffix: String = {
|
|
#if NUCLEIC_STABLE
|
|
return ""
|
|
#elseif NUCLEIC_RC
|
|
return "-rc"
|
|
#elseif NUCLEIC_BETA
|
|
return "-beta"
|
|
#elseif NUCLEIC_CANARY
|
|
return "-canary"
|
|
#else
|
|
return "-local"
|
|
#endif
|
|
}()
|
|
|
|
/// Every non-empty `channelSuffix` a build can carry. Used to tell a stable build's untagged
|
|
/// containers apart from other channels' tagged ones during disk GC. Keep in lockstep with the
|
|
/// `channelSuffix` cases above.
|
|
public nonisolated static let nonReleaseSuffixes = ["-local", "-canary", "-beta", "-rc"]
|
|
|
|
/// Whether a container/clone name belongs to THIS build channel's namespace. The on-disk
|
|
/// container store is shared across installed builds (`ContainerEngine.defaultStorageRoot`), so
|
|
/// the launch-time disk GC uses this to reap only its own channel's clones and never another
|
|
/// build's. A non-release build owns names ending in its `channelSuffix`; the stable release owns
|
|
/// names carrying none of the non-release tags.
|
|
public nonisolated static func ownsContainer(named name: String) -> Bool {
|
|
channelSuffix.isEmpty
|
|
? !nonReleaseSuffixes.contains(where: name.hasSuffix)
|
|
: name.hasSuffix(channelSuffix)
|
|
}
|
|
|
|
/// Stable container name for a session. Lowercased because `UUID().uuidString` (hence
|
|
/// `SessionID.short`) is uppercase, while container names are conventionally lowercase
|
|
/// and our reconcile parser matches lowercase hex. Carries the per-channel `channelSuffix`.
|
|
public nonisolated static func containerName(for session: SessionID) -> String {
|
|
"nucleic-\(session.short.lowercased())\(channelSuffix)"
|
|
}
|
|
|
|
/// The single **primary** container shared by all Nucleic Control projects that haven't
|
|
/// opted into per-session containers. Deliberately not of the `nucleic-<8 hex>` shape, so
|
|
/// the reconcile/list parser never mistakes it for a per-session container (and so a
|
|
/// per-session `teardown` — which computes a `nucleic-<short>` name — never removes it).
|
|
/// Its busy/idle lifecycle is ref-counted by name across every control session that uses it.
|
|
/// Used by every backend when backend-splitting is off; with splitting on, each agent family
|
|
/// gets its own suffixed sibling (`-claude`/`-codex`/`-xai`) instead. Carries the per-channel
|
|
/// `channelSuffix`.
|
|
public static let sharedControlContainerName = "nucleic-control" + channelSuffix
|
|
|
|
/// The per-family shared control containers, used only when
|
|
/// `ContainerServiceSettings.splitControlContainersByBackend` is on — one sandbox per agent
|
|
/// family so competitive Claude / GPT / xAI agents can't kill each other's processes
|
|
/// ("agenticide") in a shared box. Same non-`nucleic-<8 hex>` shape as the primary, so they're
|
|
/// likewise invisible to the per-session list parser. Each carries the `channelSuffix` at the
|
|
/// very end (e.g. `nucleic-control-codex-beta`).
|
|
public static let claudeControlContainerName = "nucleic-control-claude" + channelSuffix
|
|
public static let codexControlContainerName = "nucleic-control-codex" + channelSuffix
|
|
public static let xaiControlContainerName = "nucleic-control-xai" + channelSuffix
|
|
public static let opencodeControlContainerName = "nucleic-control-opencode" + channelSuffix
|
|
public static let hermesControlContainerName = "nucleic-control-hermes" + channelSuffix
|
|
public static let cursorControlContainerName = "nucleic-control-cursor" + channelSuffix
|
|
|
|
/// Every shared control container name that can exist. Lifecycle sweeps (reconcile, teardown,
|
|
/// status, recreate) iterate this so they stay correct no matter how the split setting is
|
|
/// currently configured — e.g. turning splitting back off still reaps the per-family containers.
|
|
public static let allSharedControlContainerNames =
|
|
[sharedControlContainerName, claudeControlContainerName,
|
|
codexControlContainerName, xaiControlContainerName,
|
|
opencodeControlContainerName, hermesControlContainerName, cursorControlContainerName]
|
|
|
|
/// The shared control container a session should use, given its backend and whether
|
|
/// backend-splitting is on. With splitting off, every backend shares `nucleic-control`; with it
|
|
/// on, each agent family gets its own sandbox: Claude → `nucleic-control-claude`, GPT/Codex →
|
|
/// `nucleic-control-codex`, xAI/Grok → `nucleic-control-xai`.
|
|
public nonisolated static func sharedContainerName(
|
|
for backend: BackendID, split: Bool
|
|
) -> String {
|
|
guard split else { return sharedControlContainerName }
|
|
switch backend {
|
|
case .claudeCode: return claudeControlContainerName
|
|
case .codex, .codexExec: return codexControlContainerName
|
|
case .grok: return xaiControlContainerName
|
|
case .opencode: return opencodeControlContainerName
|
|
case .hermes: return hermesControlContainerName
|
|
case .cursorAgent: return cursorControlContainerName
|
|
}
|
|
}
|
|
|
|
// MARK: - Randomized physical names
|
|
|
|
/// The logical control-container names that run under a **randomized** physical name: the
|
|
/// per-agent-family containers. The primary `sharedControlContainerName` is excluded — when
|
|
/// splitting is off it's the lone control sandbox, so there's no sibling for an agent to target.
|
|
public nonisolated static let randomizedControlNames: Set<String> =
|
|
[claudeControlContainerName, codexControlContainerName, xaiControlContainerName,
|
|
opencodeControlContainerName, hermesControlContainerName, cursorControlContainerName]
|
|
|
|
/// Friendly type label for a shared control container's *logical* name (Control panel heading).
|
|
public nonisolated static func controlTypeLabel(forLogical logical: String) -> String {
|
|
switch logical {
|
|
case claudeControlContainerName: return "Claude"
|
|
case codexControlContainerName: return "Codex"
|
|
case xaiControlContainerName: return "xAI"
|
|
case opencodeControlContainerName: return "OpenCode"
|
|
case hermesControlContainerName: return "Hermes"
|
|
case cursorControlContainerName: return "Cursor"
|
|
default: return "Shared"
|
|
}
|
|
}
|
|
|
|
/// Dictionary of fun / curious / odd words the random sandbox suffix is drawn from. Lowercase,
|
|
/// single tokens (container names are lowercase, hyphen-delimited), and deliberately free of the
|
|
/// channel tags (`local`/`canary`/`beta`/`rc`) so a suffix can never look like a `channelSuffix` and
|
|
/// confuse the disk-GC's channel scoping.
|
|
public nonisolated static let funWords: [String] = [
|
|
"wobble", "kerfuffle", "noodle", "quokka", "zephyr", "pickle", "bumble", "gizmo",
|
|
"waffle", "snazzy", "doodad", "kumquat", "flapjack", "wombat", "gadget", "muffin",
|
|
"pretzel", "narwhal", "tangelo", "bramble", "fiddle", "galoot", "hodgepodge", "jamboree",
|
|
"kazoo", "lollop", "moxie", "nincompoop", "oodles", "persnickety", "quibble", "razzle",
|
|
"skedaddle", "tumbleweed", "umpteen", "vortex", "whirligig", "yonder", "zigzag", "bonbon",
|
|
"cattywampus", "discombobulate", "doozy", "flummox", "gobbledygook", "hullabaloo", "lollygag",
|
|
"malarkey", "nifty", "obelisk", "pumpernickel", "quagmire", "rumpus", "snickerdoodle",
|
|
"thingamajig", "widget", "zonk", "blunderbuss", "cahoots", "dingus", "epiphany", "frippery",
|
|
"gubbins", "haberdash", "iguana", "junket", "kerplunk", "limerick", "mollusk", "nimbus",
|
|
"ottoman", "platypus", "quasar", "rutabaga", "squiggle", "trundle", "ukulele", "vagabond",
|
|
"walrus", "xylophone", "yodel", "ziggurat", "abacus", "bazooka", "conundrum", "dapper",
|
|
"embiggen", "flabbergast", "gizzard", "hobnob", "inkling", "jubilee", "kerchief", "loofah",
|
|
"marmalade", "nugget", "octopus", "paprika", "quokkas", "ricochet", "sasquatch", "tadpole",
|
|
"umbrella", "verbena", "whatsit", "yowza", "zucchini", "bumblebee", "cucumber", "doohickey",
|
|
"fandango", "gargoyle", "hiccup", "jellybean", "kookaburra", "lemur", "meerkat", "noggin",
|
|
]
|
|
|
|
/// Pick a random fun word for a new sandbox.
|
|
public nonisolated static func randomFunWord() -> String { funWords.randomElement() ?? "sandbox" }
|
|
|
|
/// Compose the physical name for a randomized control container from a fun `word`:
|
|
/// `nucleic-control-<word><channelSuffix>`. Crucially the agent *family is not encoded* — all
|
|
/// split families share the `nucleic-control-…` shape — so an agent that glimpses a sibling's
|
|
/// name can't tell which family (Claude / Codex / xAI) it serves, only the trusted host UI maps
|
|
/// name → type. The channel tag stays last so the disk-GC's `ownsContainer` scoping still works.
|
|
/// Pure, for testability.
|
|
public nonisolated static func physicalControlName(word: String) -> String {
|
|
"nucleic-control-\(word)\(channelSuffix)"
|
|
}
|
|
|
|
/// The physical name a logical name currently maps to (identity until materialized).
|
|
private func physical(_ logical: String) -> String { physicalNames[logical] ?? logical }
|
|
|
|
/// Resolve (allocating on first use) the physical name for `logical`. Randomized control names
|
|
/// get a fresh fun-word physical name — distinct from every other live sandbox's, so the three
|
|
/// split families never collide on a word; everything else maps to itself.
|
|
private func materializePhysical(_ logical: String) -> String {
|
|
if let existing = physicalNames[logical] { return existing }
|
|
guard Self.randomizedControlNames.contains(logical) else {
|
|
physicalNames[logical] = logical
|
|
return logical
|
|
}
|
|
let used = Set(physicalNames.values)
|
|
var phys = Self.physicalControlName(word: Self.randomFunWord())
|
|
var tries = 0
|
|
while used.contains(phys) && tries < 50 {
|
|
phys = Self.physicalControlName(word: Self.randomFunWord())
|
|
tries += 1
|
|
}
|
|
physicalNames[logical] = phys
|
|
return phys
|
|
}
|
|
|
|
/// Ensure the session's container is up and return its name plus the host-gateway IP
|
|
/// (the container's view of the Mac, for routing the approval MCP server). Marks a turn
|
|
/// as in-flight; pair with `finished(name:)`.
|
|
public func ensureRunning(_ spec: ContainerSpec) async throws
|
|
-> (name: String, hostGateway: String)
|
|
{
|
|
cancelIdleTimer(spec.name)
|
|
active[spec.name, default: 0] += 1
|
|
idleTimeouts[spec.name] = spec.idleTimeout
|
|
do {
|
|
// The engine creates/starts the VM (or returns the already-live one) and hands back
|
|
// the host-gateway address the guest reaches us on — known at interface-allocation
|
|
// time, no in-container `ip route` probe needed. The engine runs it under the randomized
|
|
// physical name; callers keep using the stable logical name (returned here).
|
|
let phys = materializePhysical(spec.name)
|
|
let (_, gateway) = try await engine.ensureRunning(spec.renamed(phys))
|
|
return (spec.name, gateway)
|
|
} catch {
|
|
// Roll back the activity bump so a failed start doesn't pin the container busy.
|
|
finishBookkeeping(spec.name)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/// Run `argv` inside the named container, returning a process handle (forwards to the engine;
|
|
/// no lifecycle effect). The handle streams the guest process's stdio over vsock and satisfies
|
|
/// the same `ProcessHandle` contract a host spawn does.
|
|
public func exec(
|
|
name: String, workdir: String, env: [String: String], argv: [String],
|
|
uid: Int? = nil, gid: Int? = nil
|
|
) async throws -> any ProcessHandle {
|
|
try await engine.exec(
|
|
name: physical(name), workdir: workdir, env: env, argv: argv, uid: uid, gid: gid)
|
|
}
|
|
|
|
/// Mark a turn finished. When the last in-flight turn ends, arm the idle timer.
|
|
public func finished(name: String) {
|
|
finishBookkeeping(name)
|
|
}
|
|
|
|
/// Stop a throwaway container the agent created via `linux_container` (keeps its clone on disk so
|
|
/// a later `exec` can restart it). Clears its activity bookkeeping so no idle timer lingers.
|
|
public func stopAgentContainer(name: String) async {
|
|
cancelIdleTimer(name)
|
|
active[name] = nil
|
|
idleTimeouts[name] = nil
|
|
await engine.stop(name: physical(name))
|
|
}
|
|
|
|
/// Stop and delete a throwaway container the agent created via `linux_container`. Returns true if
|
|
/// it's gone (or never existed). Clears its bookkeeping and physical-name mapping.
|
|
@discardableResult
|
|
public func removeAgentContainer(name: String) async -> Bool {
|
|
cancelIdleTimer(name)
|
|
active[name] = nil
|
|
idleTimeouts[name] = nil
|
|
let removed = await engine.remove(name: physical(name))
|
|
physicalNames[name] = nil
|
|
return removed
|
|
}
|
|
|
|
private func finishBookkeeping(_ name: String) {
|
|
let remaining = max(0, (active[name] ?? 0) - 1)
|
|
active[name] = remaining
|
|
if remaining == 0 { armIdleTimer(name) }
|
|
}
|
|
|
|
/// Stop and remove the session's container. When `waitForActive` is true, first wait for
|
|
/// any in-flight turn in that container to finish (graceful — used when sandboxing is
|
|
/// turned off for a project); otherwise remove immediately (archive/delete). Returns the
|
|
/// container name if it could NOT be removed, so the caller can warn the user; `nil` on
|
|
/// success (including when no such container existed).
|
|
@discardableResult
|
|
public func teardown(_ session: SessionID, waitForActive: Bool = false) async -> String? {
|
|
let name = Self.containerName(for: session)
|
|
if waitForActive { await waitUntilIdle(name) }
|
|
cancelIdleTimer(name)
|
|
active[name] = nil
|
|
idleTimeouts[name] = nil
|
|
let removed = await engine.remove(name: physical(name))
|
|
physicalNames[name] = nil
|
|
return removed ? nil : name
|
|
}
|
|
|
|
/// Stop and remove the shared primary control container(s) and clear their bookkeeping. Skips
|
|
/// any container with a turn still in flight (so a sibling control session isn't yanked out)
|
|
/// and any that doesn't exist. Sweeps every shared name — both the Claude and the split-out
|
|
/// Codex container — so a session of either family is reaped. Per-session `teardown` can't
|
|
/// reach these (it derives a `nucleic-<short>` name), so AppStore calls this once the last
|
|
/// control session is gone.
|
|
public func teardownShared() async {
|
|
for name in Self.allSharedControlContainerNames {
|
|
guard (active[name] ?? 0) == 0 else { continue }
|
|
cancelIdleTimer(name)
|
|
active[name] = nil
|
|
idleTimeouts[name] = nil
|
|
await engine.remove(name: physical(name))
|
|
physicalNames[name] = nil // next creation re-randomizes
|
|
await onSharedContainerRemoved?(name) // release the container-scoped approval server + socket
|
|
}
|
|
}
|
|
|
|
/// Health of the shared Nucleic Control container(s), for the Control panel. Reports running /
|
|
/// exists if *any* shared control container (Claude or the split-out Codex one) is up. Best-
|
|
/// effort: a running probe (`exec true`) first — the common case — falling back to an existence
|
|
/// check only when none is running.
|
|
public func controlContainerStatus() async -> (exists: Bool, running: Bool) {
|
|
var exists = false
|
|
for name in Self.allSharedControlContainerNames {
|
|
let phys = physical(name)
|
|
if await engine.isRunning(phys) { return (true, true) }
|
|
if await engine.containerExists(phys) { exists = true }
|
|
}
|
|
return (exists, false)
|
|
}
|
|
|
|
/// The shared control containers that currently exist this run, each with its friendly type
|
|
/// label and its **actual (randomized) name**, for the Control panel — which shows the type as
|
|
/// the heading and the opaque name as subtext. Only materialized containers appear.
|
|
public func controlContainers() async -> [ControlContainerEntry] {
|
|
var out: [ControlContainerEntry] = []
|
|
for logical in Self.allSharedControlContainerNames {
|
|
let phys = physical(logical)
|
|
let running = await engine.isRunning(phys)
|
|
var exists = running
|
|
if !exists { exists = await engine.containerExists(phys) }
|
|
guard exists else { continue }
|
|
out.append(ControlContainerEntry(
|
|
typeLabel: Self.controlTypeLabel(forLogical: logical), name: phys, running: running))
|
|
}
|
|
return out
|
|
}
|
|
|
|
/// Live CPU/memory usage of the running shared control container, for the Control panel's
|
|
/// resource monitor. Samples the first shared control container that's actually up (Claude's
|
|
/// `nucleic-control` or the split-out Codex one) — the resources are shared, so either reading
|
|
/// reflects the load. Best-effort: `nil` when none is running or the probe fails.
|
|
public func controlContainerUsage() async -> ContainerResourceSample? {
|
|
// Sample each shared name directly — the probe is an in-process `statistics()` read, which
|
|
// returns nil against a stopped/absent container, so no separate running check is needed.
|
|
// The first that yields a reading wins, so the common single-container case costs one read.
|
|
for name in Self.allSharedControlContainerNames {
|
|
if let sample = await engine.sampleResourceUsage(name: physical(name)) { return sample }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// The shared engine's in-flight artifact download (kernel / init / image pull + unpack), for
|
|
/// the Control panel's progress bar and the chat's delay hint. One engine backs every container,
|
|
/// so this is a single, cheap read (no I/O); `nil` whenever everything is cached and nothing is
|
|
/// downloading.
|
|
public func controlDownloadProgress() async -> ContainerDownloadProgress? {
|
|
await engine.currentDownloadProgress()
|
|
}
|
|
|
|
/// Post-mortem for an agent that exited 137/SIGKILL in container `name`: was the container
|
|
/// itself taken down, did the kernel OOM-kill, or was it killed while healthy? Forwards to the
|
|
/// runtime; pure lookup, no lifecycle effect. Best-effort.
|
|
public func diagnoseContainerKill(name: String) async -> ContainerKillDiagnosis {
|
|
await engine.diagnoseKill(name: physical(name))
|
|
}
|
|
|
|
/// Suspend until no turn is in flight in `name` (or a safety cap elapses, so a wedged
|
|
/// turn can't block cleanup forever). Awaiting releases the actor, letting `finished`
|
|
/// run and decrement the count.
|
|
private func waitUntilIdle(_ name: String) async {
|
|
let pollNanos: UInt64 = 200_000_000 // 0.2s
|
|
let maxPolls = 3_000 // ~10 min ceiling
|
|
var polls = 0
|
|
while (active[name] ?? 0) > 0 && polls < maxPolls {
|
|
try? await Task.sleep(nanoseconds: pollNanos)
|
|
polls += 1
|
|
}
|
|
}
|
|
|
|
/// On app launch, reconcile on-disk container artifacts. Daemonless: no VM survives the app
|
|
/// process, so there are no live orphans to kill — this is on-disk GC. It keeps only the active
|
|
/// sessions' rootfs clones and drops everything else, including the shared control containers'
|
|
/// clones (so they rebuild fresh on next use, picking up the current bind-mount paths) and image
|
|
/// caches superseded by a bundled-image bump.
|
|
public func reconcile(activeSessions: [SessionID]) async {
|
|
let keep = Set(activeSessions.map { Self.containerName(for: $0) })
|
|
await engine.reconcileDisk(keepNames: keep)
|
|
}
|
|
|
|
/// Force a fresh shared Nucleic Control sandbox: stop and remove the shared container and
|
|
/// delete the bundled default image, so the next control session rebuilds the image from the
|
|
/// current Dockerfile and recreates the container. Unconditional (ignores the busy ref-count)
|
|
/// — it's a deliberate "rebuild now" from Settings; a control session mid-turn will have its
|
|
/// container yanked and must retry. Returns once teardown + image delete complete.
|
|
public func recreateShared() async {
|
|
for name in Self.allSharedControlContainerNames {
|
|
cancelIdleTimer(name)
|
|
active[name] = nil
|
|
idleTimeouts[name] = nil
|
|
await engine.remove(name: physical(name))
|
|
physicalNames[name] = nil // force a fresh random name on the next creation
|
|
await onSharedContainerRemoved?(name) // release the container-scoped approval server + socket
|
|
}
|
|
await engine.removeDefaultRootfs()
|
|
}
|
|
|
|
/// Restart the shared control container(s) in place to reclaim the VM memory the guest holds
|
|
/// onto while running (freed pages aren't returned to the host until the VM is torn down). Keeps
|
|
/// the cached rootfs and the per-container instrumentation (the engine reuses the existing rootfs
|
|
/// clone and skips re-seeding), so it's much lighter than `recreateShared`. Skips any container
|
|
/// with a turn in flight, so live work isn't
|
|
/// yanked — the user can retry once it's idle. Re-arms nothing; the next turn's `ensureRunning`
|
|
/// takes over the idle bookkeeping.
|
|
public func restartShared() async {
|
|
for name in Self.allSharedControlContainerNames {
|
|
guard (active[name] ?? 0) == 0 else { continue }
|
|
cancelIdleTimer(name)
|
|
await engine.restart(name: physical(name)) // same name — reuses the clone
|
|
}
|
|
}
|
|
|
|
/// Force an immediate memory-reclaim (balloon) pass across all live containers instead of
|
|
/// waiting for the autoballoon loop's next tick. A no-op for any container whose policy is disabled.
|
|
public func reclaimMemoryNow() async {
|
|
await engine.reclaimMemoryNow()
|
|
}
|
|
|
|
// MARK: - Idle timer
|
|
|
|
private func armIdleTimer(_ name: String) {
|
|
cancelIdleTimer(name)
|
|
let timeout = idleTimeouts[name] ?? TimeInterval(ProjectSandbox.defaultIdleTimeoutSeconds)
|
|
idleTimers[name] = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
|
guard !Task.isCancelled else { return }
|
|
await self?.stopIfIdle(name)
|
|
}
|
|
}
|
|
|
|
private func cancelIdleTimer(_ name: String) {
|
|
idleTimers[name]?.cancel()
|
|
idleTimers[name] = nil
|
|
}
|
|
|
|
private func stopIfIdle(_ name: String) async {
|
|
idleTimers[name] = nil
|
|
guard (active[name] ?? 0) == 0 else { return } // a turn snuck in; leave it running
|
|
await engine.stop(name: physical(name)) // keep the mapping — idle-restart reuses the clone
|
|
}
|
|
}
|