v0.90.0 — guest RAM resize (R-24) + fast-tick-until-convergence (R-28)
MinAgent coupling: felhom-controller v0.143.0 gates its guest-memory-resize UI on this agent (FeatureGuestMemoryResize, MinAgent 0.90.0). R-24 guest RAM resize (internal/localapi/guestmemory.go): self-scoped GET/POST /guest/memory. Agent enforces every bound FRESH per request (min 2048, max host_total-2048, shrink floor max(2048, usage+512)); applies via PVE SetConfig — live cgroup apply, no reboot (Phase-0 proven on the nested demo box). Verify-after-apply re-reads maxmem before claiming success. New narrow MemoryOps seam (GuestAPI untouched); Options.Memory nil -> 503. Memory only. R-28 fast-tick (internal/fasttick): while any desired-state item is unapplied - including the pre-tunnel window a hub poke can't reach - pulse the shared out-of-band trigger every 30s, self-disarm on convergence. Four cached sources (desired-gen==0, reconcile Planned-Pending>0, pbsdr waiting_secret only, wgtunnel desired-not-operational); LOUD pbsdr states + pending_signature excluded. Seams: reconcile.Engine.LastResult() + wgtunnel.Manager.TunnelConvergence() (cached, no per-tick exec). Guests-0/0: hypothesis REFUTED live (9201 IS a pool member; 0/0 was the pre-provision window; PoolAddVMID re-assert already covers restore-over-existing). No code change; the fast-tick mitigates the window. Tests + red-proofs (i floor guard, ii max guard, iii always-pulse) all restored green.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
package fasttick
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
// flagSource is a fake Source whose convergence the test flips at will.
|
||||
type flagSource struct {
|
||||
unconverged bool
|
||||
reason string
|
||||
}
|
||||
|
||||
func (f *flagSource) Unconverged() (bool, string) { return f.unconverged, f.reason }
|
||||
|
||||
// drain reports how many pulses are queued (channel is cap-1 in production; tests may use larger).
|
||||
func drain(ch chan struct{}) int {
|
||||
n := 0
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
n++
|
||||
default:
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// D1 — any source unconverged → one pulse per tick (drained between ticks).
|
||||
func TestFastTick_UnconvergedPulses(t *testing.T) {
|
||||
out := make(chan struct{}, 1)
|
||||
src := &flagSource{unconverged: true, reason: "test drift"}
|
||||
l := New(out, time.Hour, quiet(), src)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.step() {
|
||||
t.Fatalf("tick %d: step reported converged, want unconverged", i)
|
||||
}
|
||||
if got := drain(out); got != 1 {
|
||||
t.Fatalf("tick %d: pulses = %d, want 1", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// D2 — ALL sources converged → zero pulses across N ticks (the emergent disarm).
|
||||
func TestFastTick_ConvergedSilent(t *testing.T) {
|
||||
out := make(chan struct{}, 4)
|
||||
l := New(out, time.Hour, quiet(), &flagSource{unconverged: false}, &flagSource{unconverged: false})
|
||||
for i := 0; i < 5; i++ {
|
||||
if l.step() {
|
||||
t.Fatalf("tick %d: step reported unconverged with all sources converged", i)
|
||||
}
|
||||
}
|
||||
if got := drain(out); got != 0 {
|
||||
t.Fatalf("pulses on a fully-converged box = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// D3 (the ruled red-proof) — flip unconverged→converged mid-run → pulses STOP from the next tick.
|
||||
func TestFastTick_ConvergenceDisarms(t *testing.T) {
|
||||
out := make(chan struct{}, 8)
|
||||
src := &flagSource{unconverged: true, reason: "drift"}
|
||||
l := New(out, time.Hour, quiet(), src)
|
||||
|
||||
// Two unconverged ticks pulse.
|
||||
l.step()
|
||||
l.step()
|
||||
if got := drain(out); got != 2 {
|
||||
t.Fatalf("pre-convergence pulses = %d, want 2", got)
|
||||
}
|
||||
// Converge.
|
||||
src.unconverged = false
|
||||
// Every subsequent tick is silent — the cadence returns to normal.
|
||||
for i := 0; i < 4; i++ {
|
||||
if l.step() {
|
||||
t.Fatalf("post-convergence tick %d still unconverged", i)
|
||||
}
|
||||
}
|
||||
if got := drain(out); got != 0 {
|
||||
t.Fatalf("pulses fired after convergence = %d, want 0 (disarm failed)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// D4 — the out channel is already full (a poke just landed): the non-blocking send drops, no block,
|
||||
// no goroutine leak, no queue growth.
|
||||
func TestFastTick_ChannelFullDrops(t *testing.T) {
|
||||
out := make(chan struct{}, 1)
|
||||
out <- struct{}{} // pre-fill: an out-of-band report is already pending
|
||||
l := New(out, time.Hour, quiet(), &flagSource{unconverged: true, reason: "drift"})
|
||||
|
||||
done := make(chan bool, 1)
|
||||
go func() {
|
||||
l.step() // must NOT block on the full channel
|
||||
l.step()
|
||||
done <- true
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("step blocked on a full channel (non-blocking send violated)")
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("channel depth = %d, want 1 (coalesced, no queue growth)", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// Priority ordering: the first unconverged source supplies the reason.
|
||||
func TestFastTick_FirstReasonWins(t *testing.T) {
|
||||
out := make(chan struct{}, 1)
|
||||
l := New(out, time.Hour, quiet(),
|
||||
&flagSource{unconverged: false},
|
||||
&flagSource{unconverged: true, reason: "second"},
|
||||
&flagSource{unconverged: true, reason: "third"})
|
||||
if u, r := l.evaluate(); !u || r != "second" {
|
||||
t.Fatalf("evaluate = (%v, %q), want (true, second)", u, r)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user