Skip to content

Commit 967e90d

Browse files
committed
fix: prevent orphan VMM via socket tiebreaker + status one-shot default
Closes #48 and #49 — both were triggers for orphan cocoon/CH processes observed on cocoonset-gke-private (8 alive CH/FC without DB entries, plus 4-day-old cocoon-CLI watchers from prior vk-cocoon restarts). #49: vm rm --force could clean the DB but leave CH running when the pidfile/cmdline check returned a false negative (stale pidfile, recycled PID, manual rundir tampering). Add Backend.IsAPISocketLive as a pidfile-independent tiebreaker (utils.CheckSocket) and call it in DeleteAll after the stop step. Fires unconditionally — AF_UNIX has no TIME_WAIT and TerminateProcess only returns nil after full pid reap, so a successful stop guarantees the listener is gone; firing also catches the pidfd_linux.go's own VerifyProcessCmdline false-negative path that otherwise returns (true, nil) without sending a signal. #48: cocoon vm status defaulted to a 5s polling loop. When invoked from a shell that disconnected without SIGHUP propagation (sudo wrapper, bash -c pipeline, tmux killed without huponexit), the loop survived indefinitely — observed 21-day-old orphans on prod. Add --watch flag; default is one-shot snapshot via statusOnce. --event still implies streaming events. statusOnce + List share a renderVMList helper so the JSON/table/empty-list branch lives in one place.
1 parent 0324e80 commit 967e90d

4 files changed

Lines changed: 62 additions & 16 deletions

File tree

cmd/vm/commands.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,13 @@ func Command(h Actions) *cobra.Command {
165165

166166
statusCmd := &cobra.Command{
167167
Use: "status [VM...]",
168-
Short: "Watch VM status in real time",
168+
Short: "Show VM status; --watch for refresh loop, --event for streaming",
169169
RunE: h.Status,
170170
}
171-
statusCmd.Flags().IntP("interval", "n", 5, "poll interval in seconds") //nolint:mnd
172-
statusCmd.Flags().Bool("event", false, "event stream mode (append changes instead of refreshing)")
173-
statusCmd.Flags().String("format", "", "output format: json (event mode only)")
171+
statusCmd.Flags().IntP("interval", "n", 5, "poll interval in seconds (only with --watch or --event)") //nolint:mnd
172+
statusCmd.Flags().BoolP("watch", "w", false, "refresh-loop mode (full-screen redraw each tick); omit for one-shot snapshot")
173+
statusCmd.Flags().Bool("event", false, "event stream mode (append changes instead of refreshing); implies polling")
174+
statusCmd.Flags().String("format", "", "output format: json (one-shot + event modes; --watch always renders a table)")
174175

175176
vmCmd.AddCommand(
176177
createCmd,

cmd/vm/status.go

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,23 @@ func (h Handler) List(cmd *cobra.Command, _ []string) error {
5656
if err != nil {
5757
return fmt.Errorf("list: %w", err)
5858
}
59+
sortVMs(vms)
60+
format, _ := cmd.Flags().GetString("format")
61+
return renderVMList(vms, format)
62+
}
63+
64+
// renderVMList emits vms as JSON or table; "No VMs found." for empty in table mode.
65+
func renderVMList(vms []*types.VM, format string) error {
66+
if format == "json" {
67+
return cmdcore.OutputJSON(vms)
68+
}
5969
if len(vms) == 0 {
6070
fmt.Println("No VMs found.")
6171
return nil
6272
}
63-
64-
sortVMs(vms)
65-
66-
return cmdcore.OutputFormatted(cmd, vms, func(w *tabwriter.Writer) {
67-
printVMTable(w, vms)
68-
})
73+
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
74+
printVMTable(w, vms)
75+
return w.Flush()
6976
}
7077

7178
func (h Handler) Status(cmd *cobra.Command, args []string) error {
@@ -79,19 +86,22 @@ func (h Handler) Status(cmd *cobra.Command, args []string) error {
7986
interval = 5 //nolint:mnd
8087
}
8188
eventMode, _ := cmd.Flags().GetBool("event")
89+
watchMode, _ := cmd.Flags().GetBool("watch")
90+
format, _ := cmd.Flags().GetString("format")
8291

8392
hypers, hyperErr := cmdcore.InitAllHypervisors(conf)
8493
if hyperErr != nil {
8594
return hyperErr
8695
}
8796

88-
watchCh := mergeWatchChannels(ctx, hypers)
97+
if !eventMode && !watchMode {
98+
return statusOnce(ctx, hypers, args, format)
99+
}
89100

101+
watchCh := mergeWatchChannels(ctx, hypers)
90102
ticker := time.NewTicker(time.Duration(interval) * time.Second)
91103
defer ticker.Stop()
92104

93-
format, _ := cmd.Flags().GetString("format")
94-
95105
if eventMode {
96106
if format == "json" {
97107
statusEventLoopJSON(ctx, hypers, args, watchCh, ticker.C)
@@ -105,6 +115,17 @@ func (h Handler) Status(cmd *cobra.Command, args []string) error {
105115
return nil
106116
}
107117

118+
// statusOnce prints a single snapshot then returns; propagates ListAllVMs error (loop callers swallow).
119+
func statusOnce(ctx context.Context, hypers []hypervisor.Hypervisor, filters []string, format string) error {
120+
vms, err := cmdcore.ListAllVMs(ctx, hypers)
121+
if err != nil {
122+
return fmt.Errorf("status: %w", err)
123+
}
124+
vms = applyFilters(vms, filters)
125+
sortVMs(vms)
126+
return renderVMList(vms, format)
127+
}
128+
108129
func mergeWatchChannels(ctx context.Context, hypers []hypervisor.Hypervisor) <-chan struct{} {
109130
var channels []<-chan struct{}
110131
for _, h := range hypers {
@@ -266,23 +287,28 @@ func printEventRow(w *tabwriter.Writer, event string, snap vmSnapshot) {
266287
snap.ip, snap.image)
267288
}
268289

290+
// listAndFilter swallows backend errors with a warn so a transient hiccup can't break the polling tick; one-shot callers must use cmdcore.ListAllVMs directly.
269291
func listAndFilter(ctx context.Context, hypers []hypervisor.Hypervisor, filters []string) []*types.VM {
270292
vms, err := cmdcore.ListAllVMs(ctx, hypers)
271293
if err != nil {
272294
log.WithFunc("cmd.vm.listAndFilter").Warnf(ctx, "list: %v", err)
273295
return nil
274296
}
275297
sortVMs(vms)
298+
return applyFilters(vms, filters)
299+
}
300+
301+
func applyFilters(vms []*types.VM, filters []string) []*types.VM {
276302
if len(filters) == 0 {
277303
return vms
278304
}
279-
var result []*types.VM
305+
out := make([]*types.VM, 0, len(vms))
280306
for _, vm := range vms {
281307
if matchesFilter(vm, filters) {
282-
result = append(result, vm)
308+
out = append(out, vm)
283309
}
284310
}
285-
return result
311+
return out
286312
}
287313

288314
func matchesFilter(vm *types.VM, filters []string) bool {

hypervisor/state.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"io/fs"
8+
"syscall"
89
"time"
910

1011
"github.com/projecteru2/core/log"
@@ -25,6 +26,20 @@ func (b *Backend) WithRunningVM(ctx context.Context, rec *VMRecord, fn func(pid
2526
return fn(pid)
2627
}
2728

29+
// IsAPISocketLive is a pidfile-independent liveness tiebreaker; fails closed on uncertain dial errors.
30+
func (b *Backend) IsAPISocketLive(ctx context.Context, rec *VMRecord) bool {
31+
sock := SocketPath(rec.RunDir)
32+
err := utils.CheckSocket(sock)
33+
if err == nil {
34+
return true
35+
}
36+
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ECONNREFUSED) {
37+
return false
38+
}
39+
log.WithFunc(b.Typ+".IsAPISocketLive").Warnf(ctx, "uncertain socket probe %s: %v — failing closed", sock, err)
40+
return true
41+
}
42+
2843
// WithPausedVM pauses, runs fn, resumes; eager resume on success promotes its error, deferred resume on fn-error only logs.
2944
func (b *Backend) WithPausedVM(ctx context.Context, rec *VMRecord, pause, resume, fn func() error) error {
3045
return b.WithRunningVM(ctx, rec, func(_ int) error {

hypervisor/stop.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ func (b *Backend) DeleteAll(ctx context.Context, refs []string, force bool, stop
6464
}); runningErr != nil && !errors.Is(runningErr, ErrNotRunning) {
6565
return fmt.Errorf("stop before delete: %w", runningErr)
6666
}
67+
// Socket probe runs regardless of stop outcome: AF_UNIX has no TIME_WAIT so a real stop guarantees no listener; firing always also catches false-negative pidfile/cmdline shortcuts.
68+
if b.IsAPISocketLive(ctx, &rec) {
69+
return fmt.Errorf("refuse delete: api socket %s still responsive (suspected orphan vmm; kill the vmm process then retry)", SocketPath(rec.RunDir))
70+
}
6771
if rmErr := RemoveVMDirs(rec.RunDir, rec.LogDir); rmErr != nil {
6872
return fmt.Errorf("cleanup VM dirs: %w", rmErr)
6973
}

0 commit comments

Comments
 (0)