Merge nucleic/warm-dewy-egret into dev

This commit is contained in:
2026-07-10 03:51:53 -07:00
parent 32221b396e
commit 8852b047b2
5 changed files with 112 additions and 10 deletions
@@ -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
}
+74 -6
View File
@@ -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 stopreboot, but unlike stop it does
/// NOT give the RAM back). Best-effort/idempotent: a no-op returning `false` when the guest can't
+9 -3
View File
@@ -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 {
+13
View File
@@ -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
+15
View File
@@ -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