Merge nucleic/warm-dewy-egret into dev
This commit is contained in:
@@ -64,7 +64,7 @@ final class SidebarColumnController {
|
||||
private weak var item: NSSplitViewItem?
|
||||
private var resizeObserver: NSObjectProtocol?
|
||||
|
||||
init(fixedWidth: CGFloat = Self.defaultFixedWidth) {
|
||||
init(fixedWidth: CGFloat = SidebarColumnController.defaultFixedWidth) {
|
||||
self.fixedWidth = fixedWidth
|
||||
}
|
||||
|
||||
|
||||
@@ -258,9 +258,13 @@ public actor MacVMEngine {
|
||||
}
|
||||
if let inFlight = boots[spec.name] { return try await inFlight.value }
|
||||
// Reclaim the stalest done chat's VM if the policy offers one; its clone stays on disk, so the
|
||||
// evicted chat reboots transparently on its next call (exactly like an idle-timer stop).
|
||||
// evicted chat reboots transparently on its next call (exactly like an idle-timer stop) — and
|
||||
// so, like that stop, this one is graceful. The victim is a *done* chat, hence typically
|
||||
// suspended and quick to halt; the grace period is bounded well inside `vmQueueTimeoutSeconds`,
|
||||
// so a wedged victim can't stall the boot we're queued behind past its own ceiling error.
|
||||
if let victim = await pickEvictable(excluding: spec.name) {
|
||||
await stop(name: victim)
|
||||
await shutdown(
|
||||
name: victim, timeout: TimeInterval(MacVMSettings.gracefulShutdownTimeoutSeconds))
|
||||
continue
|
||||
}
|
||||
// Nothing reclaimable — every occupied slot is an actively-working chat. Queue until one
|
||||
@@ -488,8 +492,13 @@ public actor MacVMEngine {
|
||||
return (lines.joined(separator: "\n"), truncated)
|
||||
}
|
||||
|
||||
/// Stop the guest (freeing its host RAM) but keep the on-disk clone bundle so the next
|
||||
/// Force-stop the guest (freeing its host RAM) but keep the on-disk clone bundle so the next
|
||||
/// `ensureRunning` reboots it without re-installing. Drops the registry entry; best-effort.
|
||||
///
|
||||
/// Cuts virtual power without letting the guest quiesce, so call it directly only when the clone is
|
||||
/// about to be deleted (``remove(name:)``) or the boot already failed. When the clone will be booted
|
||||
/// again, go through ``shutdown(name:timeout:)``, which asks the guest to halt first and falls back
|
||||
/// to this.
|
||||
public func stop(name: String) async {
|
||||
guard let entry = live[name] else { return }
|
||||
if entry.computerUse { await surfaceHost?.detach(name: name) }
|
||||
@@ -502,6 +511,32 @@ public actor MacVMEngine {
|
||||
signalSlotFreed() // a slot just freed — wake a boot parked on the admission queue
|
||||
}
|
||||
|
||||
/// Shut the guest down the way a real machine powers off: ask it to halt itself, give it a bounded
|
||||
/// grace period, and cut power only if it refuses or overruns. Use this — not ``stop(name:)`` —
|
||||
/// wherever the on-disk clone **survives and is rebooted later**, because ``stop(name:)`` yanks
|
||||
/// virtual power from a mounted, writable root filesystem, and the next `ensureRunning` then boots
|
||||
/// that unclean filesystem. (``remove(name:)``/teardown may still stop forcefully: the clone is
|
||||
/// about to be deleted, so there is nothing left to corrupt and no reason to wait.)
|
||||
///
|
||||
/// A *suspended* guest is thawed first: VZ reports `canRequestStop == false` while paused, so the
|
||||
/// request would otherwise be refused and we'd silently fall back to cutting power — precisely the
|
||||
/// state a Done chat's VM sits in when its idle timer fires.
|
||||
public func shutdown(name: String, timeout: TimeInterval) async {
|
||||
#if arch(arm64)
|
||||
if let entry = live[name] {
|
||||
if entry.paused { _ = await resume(name: name) }
|
||||
if await entry.instance.requestStop() {
|
||||
_ = await entry.instance.waitUntilStopped(timeout: timeout)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// Either the guest halted itself (VZ then reports `canStop == false`, so the forceful stop
|
||||
// inside `stop(name:)` no-ops) or it refused/overran and we cut power here. Either way this
|
||||
// performs the registry cleanup — idempotently with the `handleGuestStopped` hook a clean
|
||||
// power-off fires, since both guard on `live[name]` and only one can win.
|
||||
await stop(name: name)
|
||||
}
|
||||
|
||||
/// Suspend (pause/freeze) the named running guest in place: it keeps its host RAM but stops
|
||||
/// consuming CPU, and ``resume(name:)`` thaws it instantly. Cheaper to resume than a ``stop``→boot,
|
||||
/// but (unlike stop) does NOT free the guest's RAM. Returns `true` once paused; `false` when the VM
|
||||
@@ -536,10 +571,12 @@ public actor MacVMEngine {
|
||||
public func isPaused(_ name: String) async -> Bool { live[name]?.paused ?? false }
|
||||
|
||||
/// Stop then re-boot the guest in place (reuses the clone; reclaims the host RAM a long-running VM
|
||||
/// holds). No-op if the engine never saw this VM.
|
||||
/// holds). No-op if the engine never saw this VM. Shuts down gracefully — the clone this reboots is
|
||||
/// the very filesystem a forceful stop would leave unclean.
|
||||
public func restart(name: String) async {
|
||||
guard let spec = lastSpec[name] else { await stop(name: name); return }
|
||||
await stop(name: name)
|
||||
let grace = TimeInterval(MacVMSettings.gracefulShutdownTimeoutSeconds)
|
||||
guard let spec = lastSpec[name] else { await shutdown(name: name, timeout: grace); return }
|
||||
await shutdown(name: name, timeout: grace)
|
||||
_ = try? await ensureRunning(spec)
|
||||
}
|
||||
|
||||
@@ -963,6 +1000,37 @@ final class MacVMInstance: NSObject, VZVirtualMachineDelegate, @unchecked Sendab
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the guest to shut *itself* down (VZ delivers a power-button / ACPI request, which systemd on
|
||||
/// Linux and macOS both honor) instead of cutting its virtual power. Returns `false` when the guest
|
||||
/// won't accept the request — notably **while paused**, where VZ reports `canRequestStop == false`,
|
||||
/// so thaw a suspended guest before calling this. Pair with ``waitUntilStopped(timeout:)``: the
|
||||
/// request only *initiates* the shutdown, it doesn't wait for it.
|
||||
@discardableResult
|
||||
func requestStop() async -> Bool {
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
queue.async { [self] in
|
||||
guard vm.canRequestStop else { cont.resume(returning: false); return }
|
||||
do { try vm.requestStop(); cont.resume(returning: true) }
|
||||
catch { cont.resume(returning: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll (bounded) until the guest reaches `.stopped`, returning whether it got there in time. Polls
|
||||
/// rather than hooking `setOnStop`, because a *live* VM's one-shot stop hook already belongs to the
|
||||
/// engine's ``MacVMEngine/handleGuestStopped(_:)`` registry cleanup and must not be stolen.
|
||||
func waitUntilStopped(timeout: TimeInterval) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
let stopped = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
queue.async { [self] in cont.resume(returning: vm.state == .stopped) }
|
||||
}
|
||||
if stopped { return true }
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Pause (freeze) the running guest in place: it stays resident in host RAM but consumes no CPU,
|
||||
/// and ``resume()`` thaws it instantly (much cheaper than a stop→reboot, but — unlike stop — it does
|
||||
/// NOT give the RAM back). Best-effort/idempotent: a no-op returning `false` when the guest can't
|
||||
|
||||
@@ -274,12 +274,14 @@ public actor MacVMManager {
|
||||
// and, for stop/remove, clears the busy bookkeeping so the explicit action isn't second-guessed.
|
||||
|
||||
/// Stop the named VM now (frees its host RAM), keeping the on-disk clone so the next exec reboots
|
||||
/// it. Disarms the idle timer and clears the busy count — the agent explicitly took it down.
|
||||
/// it. Disarms the idle timer and clears the busy count — the agent explicitly took it down. The
|
||||
/// clone is rebooted later, so the guest is asked to halt itself before its power is cut.
|
||||
public func stop(name: String) async {
|
||||
cancelIdleTimer(name)
|
||||
suspendWhenIdle.remove(name)
|
||||
active[name] = nil
|
||||
await engine.stop(name: name)
|
||||
await engine.shutdown(
|
||||
name: name, timeout: TimeInterval(MacVMSettings.gracefulShutdownTimeoutSeconds))
|
||||
}
|
||||
|
||||
/// Suspend (freeze) the named VM in place: keeps its RAM but pauses CPU; ``resume(name:)`` thaws it
|
||||
@@ -504,7 +506,11 @@ public actor MacVMManager {
|
||||
private func stopIfIdle(_ name: String) async {
|
||||
idleTimers[name] = nil
|
||||
guard (active[name] ?? 0) == 0 else { return }
|
||||
await engine.stop(name: name) // keep the clone on disk — idle-restart reuses it
|
||||
// Keep the clone on disk — idle-restart reuses it — so let the guest halt itself rather than
|
||||
// cutting power out from under the filesystem the next boot will mount. For a Done chat the
|
||||
// guest is suspended by now; `shutdown` thaws it to deliver the request.
|
||||
await engine.shutdown(
|
||||
name: name, timeout: TimeInterval(MacVMSettings.gracefulShutdownTimeoutSeconds))
|
||||
}
|
||||
|
||||
private func waitUntilIdle(_ name: String) async {
|
||||
|
||||
@@ -670,6 +670,11 @@ public enum MacVMSettings {
|
||||
/// working peers. Only reached when every occupied slot is a busy chat (a done chat's VM is evicted
|
||||
/// first, never queued behind). See ``MacVMEngine/ensureRunning(_:)``.
|
||||
public static let vmQueueTimeoutSecondsKey = "nucleic.macvm.queueTimeoutSeconds"
|
||||
/// How long (seconds) a guest gets to power itself off after Nucleic asks it to, before its virtual
|
||||
/// power is cut. Applies wherever the on-disk clone *survives* the stop and is rebooted later — the
|
||||
/// idle timer, the agent's explicit `stop`, a `restart`, and eviction under the concurrent-VM
|
||||
/// ceiling. See ``MacVMEngine/shutdown(name:timeout:)``.
|
||||
public static let gracefulShutdownTimeoutSecondsKey = "nucleic.macvm.gracefulShutdownTimeoutSeconds"
|
||||
/// The in-guest account Nucleic SSHes in as (baked into the base by provisioning).
|
||||
public static let sshUserKey = "nucleic.macvm.sshUser"
|
||||
|
||||
@@ -683,6 +688,11 @@ public enum MacVMSettings {
|
||||
/// ride out a peer's turn wrapping up, short enough that a full house surfaces the ceiling error
|
||||
/// rather than stalling the tool call indefinitely.
|
||||
public static let defaultVMQueueTimeoutSeconds = 180
|
||||
/// Default shutdown grace (see ``gracefulShutdownTimeoutSecondsKey``): 20 seconds. An idle guest
|
||||
/// unmounts and halts well inside this; overrunning it means the guest is wedged, and cutting power
|
||||
/// is then no worse than the hard stop this replaced. Comfortably inside
|
||||
/// ``defaultVMQueueTimeoutSeconds`` so an eviction can't stall a queued boot into its ceiling error.
|
||||
public static let defaultGracefulShutdownTimeoutSeconds = 20
|
||||
public static let defaultSSHUser = "agent"
|
||||
|
||||
/// Whether the macOS-VM service is enabled app-wide.
|
||||
@@ -821,6 +831,9 @@ public enum MacVMSettings {
|
||||
public static var vmQueueTimeoutSeconds: Int {
|
||||
resource(vmQueueTimeoutSecondsKey, default: defaultVMQueueTimeoutSeconds)
|
||||
}
|
||||
public static var gracefulShutdownTimeoutSeconds: Int {
|
||||
resource(gracefulShutdownTimeoutSecondsKey, default: defaultGracefulShutdownTimeoutSeconds)
|
||||
}
|
||||
|
||||
// MARK: - Linux guests
|
||||
|
||||
|
||||
@@ -406,6 +406,9 @@ import Testing
|
||||
#expect(MacVMSettings.vmMemoryGiB == MacVMSettings.defaultVMMemoryGiB)
|
||||
#expect(MacVMSettings.maxConcurrentVMs == MacVMSettings.defaultMaxConcurrentVMs)
|
||||
#expect(MacVMSettings.vmQueueTimeoutSeconds == MacVMSettings.defaultVMQueueTimeoutSeconds)
|
||||
#expect(
|
||||
MacVMSettings.gracefulShutdownTimeoutSeconds
|
||||
== MacVMSettings.defaultGracefulShutdownTimeoutSeconds)
|
||||
#expect(MacVMSettings.sshUser == MacVMSettings.defaultSSHUser)
|
||||
#expect(MacVMSettings.basePrebuiltPath == nil)
|
||||
#expect(MacVMSettings.restoreImagePath == nil)
|
||||
@@ -415,17 +418,29 @@ import Testing
|
||||
suite.set(true, forKey: MacVMSettings.exposeByDefaultKey)
|
||||
suite.set(16, forKey: MacVMSettings.vmCPUsKey)
|
||||
suite.set(45, forKey: MacVMSettings.vmQueueTimeoutSecondsKey)
|
||||
suite.set(5, forKey: MacVMSettings.gracefulShutdownTimeoutSecondsKey)
|
||||
suite.set(" ", forKey: MacVMSettings.sshUserKey) // blank falls back to default
|
||||
suite.set("/base/bundle", forKey: MacVMSettings.basePrebuiltPathKey)
|
||||
#expect(MacVMSettings.serviceEnabled == true)
|
||||
#expect(MacVMSettings.exposeByDefault == true) // now that the service is on
|
||||
#expect(MacVMSettings.vmCPUs == 16)
|
||||
#expect(MacVMSettings.vmQueueTimeoutSeconds == 45)
|
||||
#expect(MacVMSettings.gracefulShutdownTimeoutSeconds == 5)
|
||||
#expect(MacVMSettings.sshUser == MacVMSettings.defaultSSHUser)
|
||||
#expect(MacVMSettings.basePrebuiltPath == "/base/bundle")
|
||||
}
|
||||
}
|
||||
|
||||
/// Eviction under the concurrent-VM ceiling now shuts the victim down *gracefully*, and that happens
|
||||
/// while another chat's boot is parked in the admission queue. So the shutdown grace a wedged victim
|
||||
/// can burn must stay comfortably inside the queue's own timeout — otherwise one unresponsive guest
|
||||
/// could push the boot it's blocking into a spurious `concurrencyLimit` error.
|
||||
@Test func gracefulShutdownBudgetFitsInsideTheAdmissionQueueTimeout() {
|
||||
#expect(
|
||||
MacVMSettings.defaultGracefulShutdownTimeoutSeconds
|
||||
< MacVMSettings.defaultVMQueueTimeoutSeconds)
|
||||
}
|
||||
|
||||
// MARK: - Admission / eviction ranking
|
||||
|
||||
/// Build a local `SessionSummary` with just the fields the admission policy reads. Fully qualified
|
||||
|
||||
Reference in New Issue
Block a user