Monorepo for Tangled tangled.org
9

Configure Feed

Select the types of activity you want to include in your feed.

at master 18 kB View raw
1package microvm 2 3import ( 4 "context" 5 "crypto/sha256" 6 _ "embed" 7 "encoding/hex" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "log/slog" 12 "os" 13 "os/exec" 14 "path/filepath" 15 "strconv" 16 "strings" 17 "sync" 18 "time" 19 20 "github.com/digitalocean/go-qemu/qmp" 21 "github.com/google/uuid" 22) 23 24const ( 25 defaultQMPTimeout = 10 * time.Second 26 outerSlirpCIDR = "10.0.2.0/24" 27 innerSlirpNet = "10.0.3.0/24" 28 innerSlirpHost = "10.0.3.2" 29 innerSlirpDNS = "10.0.3.3" 30 innerSlirpDHCP = "10.0.3.15" 31 netnsTapName = "tap0" 32 netnsMTU = "65520" 33) 34 35type QEMUConfig struct { 36 Image ImageSpec 37 BootTimeout time.Duration 38 CID uint32 39 EnableKVM bool 40 QEMULogPath string 41 QMPPath string 42 SerialLogPath string 43 WorkDir string 44 VolumePaths map[string]string 45 VolumeBaseName string 46 Cgroup CgroupLimits 47 Dev bool 48} 49 50type QEMUVMHandle struct { 51 cid uint32 52 Process *os.Process 53 qemuLogPath string 54 QMPMon *qmp.SocketMonitor 55 QMPPath string 56 serialLogPath string 57 workDir string 58 59 qmpSocketPath string 60 61 cmd *exec.Cmd 62 done chan struct{} 63 qemuLogFile *os.File 64 cgroup *CgroupHandle 65 slirpCmd *exec.Cmd 66 slirpExit *os.File 67 waitErr error 68 waitErrMu sync.Mutex 69} 70 71type qemuRunner struct{} 72 73func (qemuRunner) Validate(spec ImageSpec, enableKVM bool) error { 74 if _, err := exec.LookPath(spec.RunnerCmd()); err != nil { 75 return fmt.Errorf("required host command %q not found in PATH: %w", spec.RunnerCmd(), err) 76 } 77 if _, err := os.Stat("/dev/vhost-vsock"); err != nil { 78 return fmt.Errorf("microvm requires /dev/vhost-vsock for vhost-vsock-device: %w", err) 79 } 80 if enableKVM { 81 if _, err := os.Stat("/dev/kvm"); err != nil { 82 return fmt.Errorf("microvm KVM was requested but /dev/kvm is not accessible: %w", err) 83 } 84 } 85 if len(spec.NetworkInterfaces) > 0 { 86 if _, err := os.Stat("/dev/net/tun"); err != nil { 87 return fmt.Errorf("microvm slirp4netns networking requires /dev/net/tun: %w", err) 88 } 89 for _, cmd := range []string{"ip", "mount", "slirp4netns", "unshare"} { 90 if _, err := exec.LookPath(cmd); err != nil { 91 return fmt.Errorf("required host command %q not found in PATH: %w", cmd, err) 92 } 93 } 94 } 95 return nil 96} 97 98func (qemuRunner) Start(ctx context.Context, cfg VMConfig, volumePaths map[string]string, logger *slog.Logger) (VMHandle, error) { 99 bootTimeout := cfg.BootTimeout 100 if bootTimeout == 0 { 101 bootTimeout = 10 * time.Second 102 } 103 return StartQEMU(ctx, QEMUConfig{ 104 Image: cfg.Image, 105 BootTimeout: bootTimeout, 106 CID: cfg.CID, 107 EnableKVM: cfg.EnableKVM, 108 WorkDir: cfg.WorkDir, 109 VolumePaths: volumePaths, 110 Cgroup: cfg.Cgroup, 111 Dev: cfg.Dev, 112 }, logger) 113} 114 115// we hash the workDir to get a deterministic qmpSock path 116// since they are AF_UNIX sockets, they are bound by a 108 char long path limit... 117func qmpSocketPath(workDir string) string { 118 base := filepath.Dir(workDir) 119 if workDir == "" { 120 base = os.TempDir() 121 } 122 sum := sha256.Sum256([]byte(workDir)) 123 return filepath.Join(base, hex.EncodeToString(sum[:8])+".qmp.sock") 124} 125 126func StartQEMU(ctx context.Context, cfg QEMUConfig, logger *slog.Logger) (VMHandle, error) { 127 if logger == nil { 128 logger = slog.Default() 129 } 130 131 workDir := cfg.WorkDir 132 133 handle := &QEMUVMHandle{ 134 workDir: workDir, 135 } 136 137 var ok bool 138 defer func() { 139 if !ok { 140 if detail := vmCrashLog(handle); detail != "" { 141 logger.Error("microVM failed to start", "cid", handle.cid, "detail", detail) 142 } 143 _ = handle.Close() 144 } 145 }() 146 147 cid := cfg.CID 148 if cid == 0 { 149 var err error 150 cid, err = AllocateCID() 151 if err != nil { 152 return nil, err 153 } 154 } 155 if cid < minGuestCID { 156 return nil, fmt.Errorf("guest CID must be >= %d", minGuestCID) 157 } 158 handle.cid = cid 159 160 volumePaths := cfg.VolumePaths 161 162 qemuLogPath := cfg.QEMULogPath 163 if qemuLogPath == "" { 164 qemuLogPath = filepath.Join(workDir, "qemu.log") 165 } 166 qemuLogFile, err := createParentedFile(qemuLogPath) 167 if err != nil { 168 return nil, err 169 } 170 handle.qemuLogPath = qemuLogPath 171 handle.qemuLogFile = qemuLogFile 172 173 serialLogPath := cfg.SerialLogPath 174 if serialLogPath == "" { 175 serialLogPath = filepath.Join(workDir, "serial.log") 176 } 177 if err := os.MkdirAll(filepath.Dir(serialLogPath), 0o755); err != nil { 178 return nil, fmt.Errorf("create serial log directory: %w", err) 179 } 180 handle.serialLogPath = serialLogPath 181 182 qmpPath := cfg.QMPPath 183 if qmpPath == "" { 184 qmpPath = qmpSocketPath(workDir) 185 handle.qmpSocketPath = qmpPath 186 } 187 handle.QMPPath = qmpPath 188 189 qemuCmd := cfg.Image.RunnerCmd() 190 qemuBinary, err := exec.LookPath(qemuCmd) 191 if err != nil { 192 return nil, fmt.Errorf("%s command not found in PATH: %w", qemuCmd, err) 193 } 194 195 args, err := qemuArgs(qemuArgsConfig{ 196 Image: cfg.Image, 197 CID: cid, 198 EnableKVM: cfg.EnableKVM, 199 QMPPath: qmpPath, 200 SerialLogPath: serialLogPath, 201 VolumePaths: volumePaths, 202 }) 203 if err != nil { 204 return nil, err 205 } 206 207 cmd, slirpNet, err := qemuCommand(ctx, qemuBinary, args, cfg.Image, workDir, cfg.Dev) 208 if err != nil { 209 return nil, err 210 } 211 cmd.Env = append(os.Environ(), "TMPDIR="+workDir) 212 cmd.Stdout = qemuLogFile 213 cmd.Stderr = qemuLogFile 214 215 cgroup, err := prepareCgroup(cfg.Cgroup, logger) 216 if err != nil { 217 return nil, err 218 } 219 handle.cgroup = cgroup 220 221 logger.Info("starting qemu microvm", "cid", cid, "workDir", workDir, "serialLog", serialLogPath, "qmp", qmpPath) 222 if err := cmd.Start(); err != nil { 223 return nil, fmt.Errorf("starting qemu: %w", err) 224 } 225 handle.cmd = cmd 226 handle.Process = cmd.Process 227 handle.done = make(chan struct{}) 228 go func() { 229 err := cmd.Wait() 230 handle.waitErrMu.Lock() 231 handle.waitErr = err 232 handle.waitErrMu.Unlock() 233 close(handle.done) 234 }() 235 236 if err := cgroup.AddProcess(cmd.Process.Pid, logger); err != nil { 237 return nil, err 238 } 239 240 if slirpNet != nil { 241 handle.slirpCmd, handle.slirpExit, err = slirpNet.Start(ctx, qemuLogFile, logger) 242 if err != nil { 243 return nil, err 244 } 245 if handle.slirpCmd != nil && handle.slirpCmd.Process != nil { 246 if err := cgroup.AddProcess(handle.slirpCmd.Process.Pid, logger); err != nil { 247 return nil, err 248 } 249 } 250 } 251 252 qmpTimeout := cfg.BootTimeout 253 if qmpTimeout == 0 { 254 qmpTimeout = defaultQMPTimeout 255 } 256 qmpCtx, cancelQMP := context.WithTimeout(ctx, qmpTimeout) 257 defer cancelQMP() 258 if err := handle.waitForQMP(qmpCtx, qmpTimeout); err != nil { 259 return nil, err 260 } 261 262 status, err := handle.waitForQMPRunning(qmpCtx, qmpTimeout) 263 if err != nil { 264 return nil, err 265 } 266 logger.Info("qemu microvm running", "cid", cid, "status", status) 267 268 ok = true 269 return handle, nil 270} 271 272func (h *QEMUVMHandle) Wait() error { 273 if h == nil || h.done == nil { 274 return nil 275 } 276 <-h.done 277 h.waitErrMu.Lock() 278 defer h.waitErrMu.Unlock() 279 return h.waitErr 280} 281 282func (h *QEMUVMHandle) WaitContext(ctx context.Context) error { 283 if h == nil || h.done == nil { 284 return nil 285 } 286 select { 287 case <-h.done: 288 h.waitErrMu.Lock() 289 defer h.waitErrMu.Unlock() 290 return h.waitErr 291 case <-ctx.Done(): 292 return ctx.Err() 293 } 294} 295 296func (h *QEMUVMHandle) Kill() error { 297 if h == nil || h.Process == nil { 298 return nil 299 } 300 return h.Process.Kill() 301} 302 303func (h *QEMUVMHandle) Shutdown(ctx context.Context) error { 304 if h == nil { 305 return nil 306 } 307 if h.QMPMon != nil { 308 if err := h.QMPSystemPowerdown(); err != nil { 309 return err 310 } 311 } 312 if h.done == nil { 313 return nil 314 } 315 select { 316 case <-h.done: 317 return h.Wait() 318 case <-ctx.Done(): 319 _ = h.Kill() 320 _ = h.Wait() 321 return ctx.Err() 322 } 323} 324 325func (h *QEMUVMHandle) Close() error { 326 if h == nil { 327 return nil 328 } 329 330 var closeErr error 331 if h.QMPMon != nil { 332 closeErr = errors.Join(closeErr, h.QMPMon.Disconnect()) 333 h.QMPMon = nil 334 } 335 if h.Process != nil { 336 _ = h.Process.Kill() 337 _ = h.Wait() 338 } 339 if h.slirpExit != nil { 340 _ = h.slirpExit.Close() 341 h.slirpExit = nil 342 } 343 if h.slirpCmd != nil && h.slirpCmd.Process != nil { 344 _ = h.slirpCmd.Process.Kill() 345 _ = h.slirpCmd.Wait() 346 h.slirpCmd = nil 347 } 348 if h.qemuLogFile != nil { 349 closeErr = errors.Join(closeErr, h.qemuLogFile.Close()) 350 h.qemuLogFile = nil 351 } 352 if h.cgroup != nil { 353 closeErr = errors.Join(closeErr, h.cgroup.Close()) 354 h.cgroup = nil 355 } 356 if h.qmpSocketPath != "" { 357 if err := os.Remove(h.qmpSocketPath); err != nil && !os.IsNotExist(err) { 358 closeErr = errors.Join(closeErr, err) 359 } 360 h.qmpSocketPath = "" 361 } 362 return closeErr 363} 364 365func (h *QEMUVMHandle) QMPRun(command qmp.Command) ([]byte, error) { 366 if h == nil || h.QMPMon == nil { 367 return nil, fmt.Errorf("qmp monitor is not connected") 368 } 369 data, err := json.Marshal(command) 370 if err != nil { 371 return nil, err 372 } 373 return h.QMPMon.Run(data) 374} 375 376func (h *QEMUVMHandle) QMPQueryStatus() (string, error) { 377 raw, err := h.QMPRun(qmp.Command{Execute: "query-status"}) 378 if err != nil { 379 return "", fmt.Errorf("qmp query-status failed: %w", err) 380 } 381 382 var resp struct { 383 Return *struct { 384 Status string `json:"status"` 385 } `json:"return"` 386 } 387 if err := json.Unmarshal(raw, &resp); err != nil { 388 return "", fmt.Errorf("qmp query-status parse %q: %w", raw, err) 389 } 390 if resp.Return == nil { 391 return "", fmt.Errorf("qmp query-status missing return: %s", raw) 392 } 393 if resp.Return.Status == "" { 394 return "", fmt.Errorf("qmp query-status missing return.status: %s", raw) 395 } 396 return resp.Return.Status, nil 397} 398 399func (h *QEMUVMHandle) waitForQMPRunning(ctx context.Context, timeout time.Duration) (string, error) { 400 statusCtx, cancel := context.WithTimeout(ctx, timeout) 401 defer cancel() 402 403 ticker := time.NewTicker(25 * time.Millisecond) 404 defer ticker.Stop() 405 406 var lastStatus string 407 var lastErr error 408 409 for { 410 status, err := h.QMPQueryStatus() 411 if err != nil { 412 lastErr = err 413 } else { 414 lastErr = nil 415 lastStatus = status 416 417 switch status { 418 case "running": 419 return status, nil 420 case "shutdown", "internal-error", "io-error", "guest-panicked": 421 return "", fmt.Errorf("qemu guest entered unhealthy state before running (status: %s)", status) 422 } 423 } 424 425 select { 426 case <-statusCtx.Done(): 427 if lastErr != nil { 428 return "", fmt.Errorf("qemu guest did not reach running state (last status: %s): %w", lastStatus, errors.Join(statusCtx.Err(), lastErr)) 429 } 430 return "", fmt.Errorf("qemu guest did not reach running state (last status: %s): %w", lastStatus, statusCtx.Err()) 431 case <-h.done: 432 return "", fmt.Errorf("qemu exited before reaching running state: %w", h.Wait()) 433 case <-ticker.C: 434 } 435 } 436} 437 438func (h *QEMUVMHandle) QMPSystemPowerdown() error { 439 _, err := h.QMPRun(qmp.Command{Execute: "system_powerdown"}) 440 return err 441} 442 443func (h *QEMUVMHandle) Logs() VMLogs { 444 if h == nil { 445 return VMLogs{} 446 } 447 return VMLogs{ 448 Serial: h.serialLogPath, 449 Extra: map[string]string{ 450 "qemu": h.qemuLogPath, 451 }, 452 } 453} 454 455func (h *QEMUVMHandle) CID() uint32 { 456 if h == nil { 457 return 0 458 } 459 return h.cid 460} 461 462func (h *QEMUVMHandle) WorkDir() string { 463 if h == nil { 464 return "" 465 } 466 return h.workDir 467} 468 469func (h *QEMUVMHandle) OOMKilled() bool { 470 if h == nil { 471 return false 472 } 473 return h.cgroup.OOMKilled() 474} 475 476func (h *QEMUVMHandle) waitForQMP(ctx context.Context, timeout time.Duration) error { 477 qmpCtx, cancel := context.WithTimeout(ctx, timeout) 478 defer cancel() 479 480 var lastErr error 481 for { 482 mon, err := qmp.NewSocketMonitor("unix", h.QMPPath, 2*time.Second) 483 if err == nil { 484 if err = mon.Connect(); err == nil { 485 h.QMPMon = mon 486 return nil 487 } 488 _ = mon.Disconnect() 489 } 490 lastErr = err 491 492 select { 493 case <-qmpCtx.Done(): 494 return fmt.Errorf("qmp connect timeout: %w", lastErr) 495 case <-h.done: 496 return fmt.Errorf("qemu exited before qmp was ready: %w", h.Wait()) 497 case <-time.After(25 * time.Millisecond): 498 } 499 } 500} 501 502func qemuCommand( 503 ctx context.Context, 504 qemuBinary string, 505 args []string, 506 spec ImageSpec, 507 workDir string, 508 dev bool, 509) (*exec.Cmd, *slirpNamespace, error) { 510 if len(spec.NetworkInterfaces) == 0 { 511 return exec.CommandContext(ctx, qemuBinary, args...), nil, nil 512 } 513 514 ipPath, err := exec.LookPath("ip") 515 if err != nil { 516 return nil, nil, fmt.Errorf("ip command not found in PATH: %w", err) 517 } 518 mountPath, err := exec.LookPath("mount") 519 if err != nil { 520 return nil, nil, fmt.Errorf("mount command not found in PATH: %w", err) 521 } 522 unsharePath, err := exec.LookPath("unshare") 523 if err != nil { 524 return nil, nil, fmt.Errorf("unshare command not found in PATH: %w", err) 525 } 526 527 pidFile, resolvPath, wrapperPath, err := prepareQEMUNetnsFiles(workDir, dev) 528 if err != nil { 529 return nil, nil, err 530 } 531 532 cmdArgs := append([]string{ 533 "--user", 534 "--map-root-user", 535 "--net", 536 "--mount", 537 "--propagation", "private", 538 "--", 539 wrapperPath, 540 pidFile, 541 ipPath, 542 mountPath, 543 resolvPath, 544 qemuBinary, 545 }, args...) 546 547 cmd := exec.CommandContext(ctx, unsharePath, cmdArgs...) 548 549 return cmd, &slirpNamespace{ 550 spec: spec, 551 pidFile: pidFile, 552 dev: dev, 553 }, nil 554} 555 556func prepareQEMUNetnsFiles(workDir string, dev bool) (pidFile, resolvPath, wrapperPath string, err error) { 557 pidFile = filepath.Join(workDir, "qemu-netns.pid") 558 resolvPath = filepath.Join(workDir, "qemu-netns-resolv.conf") 559 wrapperPath = filepath.Join(workDir, "qemu-netns-wrapper") 560 561 // the guest resolves through shuttle on 127.0.0.1. keep qemu's slirp DNS 562 // pointed at an unroutable local resolver inside this network namespace so 563 // direct guest queries to 10.0.3.3 don't bypass the shuttle dns policy. 564 if err := os.WriteFile(resolvPath, []byte("nameserver 127.0.0.1\n"), 0o644); err != nil { 565 return "", "", "", fmt.Errorf("write qemu network namespace resolv.conf: %w", err) 566 } 567 568 if err := writeNetnsWrapper(wrapperPath, dev); err != nil { 569 return "", "", "", fmt.Errorf("write qemu network namespace wrapper: %w", err) 570 } 571 572 return pidFile, resolvPath, wrapperPath, nil 573} 574 575type qemuArgsConfig struct { 576 Image ImageSpec 577 CID uint32 578 EnableKVM bool 579 QMPPath string 580 SerialLogPath string 581 VolumePaths map[string]string 582} 583 584func qemuArgs(cfg qemuArgsConfig) ([]string, error) { 585 uuid := uuid.New() 586 587 b := newArgBuilder(64) 588 589 addQEMUMachineArgs(&b, cfg, uuid) 590 addQEMUStoreArgs(&b, cfg) 591 592 if cfg.EnableKVM { 593 addQEMUKVMArgs(&b, cfg.Image) 594 } 595 596 if err := addQEMUVolumeArgs(&b, cfg); err != nil { 597 return nil, err 598 } 599 600 if err := addQEMUNetworkArgs(&b, cfg.Image.NetworkInterfaces); err != nil { 601 return nil, err 602 } 603 604 b.Optf("-device", "vhost-vsock-device,guest-cid=%d", cfg.CID) 605 606 if len(cfg.Image.RunnerConfig.ExtraArgs) > 0 { 607 b.Add(cfg.Image.RunnerConfig.ExtraArgs...) 608 } 609 610 return b.Args(), nil 611} 612 613func addQEMUMachineArgs(b *argBuilder, cfg qemuArgsConfig, uuid uuid.UUID) { 614 if cfg.Image.RunnerConfig.Machine != "" { 615 b.Opt("-M", cfg.Image.RunnerConfig.Machine) 616 } 617 b.Optf("-m", "%dM", cfg.Image.MemoryMiB) 618 b.Opt("-smp", strconv.Itoa(cfg.Image.VCPUs)) 619 620 b.Add( 621 "-nodefaults", 622 "-no-user-config", 623 "-no-reboot", 624 ) 625 626 b.Opt("-kernel", cfg.Image.Kernel) 627 b.Opt("-initrd", cfg.Image.Initrd) 628 629 b.Opt("-device", "virtio-rng-device") 630 631 b.Optf("-smbios", "type=1,uuid=%s", uuid) 632 b.Opt("-serial", "file:"+cfg.SerialLogPath) 633 634 // use virtio console if requsted. this is faster than the serial UART logging 635 // because serial has a higher cost when being accesssed. we still have to 636 // support serial itself for early kernel boot but thats OK. 637 if cfg.Image.RunnerConfig.Console == "hvc0" { 638 b.Optf("-chardev", "file,id=virtiocon0,path=%s,append=on", cfg.SerialLogPath) 639 b.Add("-device", "virtio-serial-device") 640 b.Opt("-device", "virtconsole,chardev=virtiocon0") 641 } 642 b.Opt("-display", "none") 643 b.Opt("-monitor", "none") 644 b.Opt("-append", cfg.Image.BootArgs) 645 646 b.Opt("-sandbox", "on") 647 b.Optf("-qmp", "unix:%s,server,nowait", cfg.QMPPath) 648} 649 650func addQEMUStoreArgs(b *argBuilder, cfg qemuArgsConfig) { 651 drive := newOptionBuilder(8) 652 drive.KV("id", "store") 653 drive.KV("format", "raw") 654 drive.Add("read-only=on") 655 drive.KV("file", cfg.Image.StoreDisk) 656 drive.Add("if=none") 657 drive.Add("aio=io_uring") 658 659 b.Opt("-drive", drive.String()) 660 b.Opt("-device", "virtio-blk-device,drive=store") 661} 662 663func addQEMUKVMArgs(b *argBuilder, image ImageSpec) { 664 b.Flag("-enable-kvm") 665 if image.RunnerConfig.CPU != "" { 666 b.Opt("-cpu", image.RunnerConfig.CPU) 667 } 668 b.Opt("-device", "i8042") 669} 670 671func addQEMUVolumeArgs(b *argBuilder, cfg qemuArgsConfig) error { 672 for index, volume := range cfg.Image.Volumes { 673 path := cfg.VolumePaths[volume.Image] 674 if path == "" { 675 return fmt.Errorf("missing prepared path for volume %q", volume.Image) 676 } 677 678 driveID := fmt.Sprintf("volume%d", index) 679 680 drive := newOptionBuilder(10) 681 drive.KV("id", driveID) 682 drive.KV("format", "raw") 683 drive.Add("read-only=off") 684 drive.KV("file", path) 685 drive.Add("if=none") 686 drive.Add("aio=io_uring") 687 drive.Add("discard=unmap") 688 drive.Add("cache=none") 689 690 b.Opt("-drive", drive.String()) 691 b.Optf("-device", "virtio-blk-device,drive=%s", driveID) 692 } 693 694 return nil 695} 696 697func addQEMUNetworkArgs(b *argBuilder, interfaces []NetworkInterface) error { 698 for _, networkInterface := range interfaces { 699 if networkInterface.Type != "slirp4netns" { 700 return fmt.Errorf("unsupported microvm network interface type %q", networkInterface.Type) 701 } 702 703 netdevOpts := newOptionBuilder(6) 704 netdevOpts.Add("user") 705 netdevOpts.KV("id", networkInterface.ID) 706 netdevOpts.KV("net", innerSlirpNet) 707 netdevOpts.KV("host", innerSlirpHost) 708 netdevOpts.KV("dns", innerSlirpDNS) 709 netdevOpts.KV("dhcpstart", innerSlirpDHCP) 710 711 b.Opt("-netdev", netdevOpts.String()) 712 b.Optf( 713 "-device", "virtio-net-device,netdev=%s,mac=%s", 714 networkInterface.ID, networkInterface.MAC, 715 ) 716 } 717 718 return nil 719} 720 721func waitForPIDFile(ctx context.Context, path string) (string, error) { 722 waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second) 723 defer cancel() 724 725 ticker := time.NewTicker(25 * time.Millisecond) 726 defer ticker.Stop() 727 728 for { 729 data, err := os.ReadFile(path) 730 if err == nil { 731 pid := strings.TrimSpace(string(data)) 732 if pid != "" { 733 return pid, nil 734 } 735 } else if !errors.Is(err, os.ErrNotExist) { 736 return "", fmt.Errorf("read qemu network namespace pid: %w", err) 737 } 738 739 select { 740 case <-waitCtx.Done(): 741 return "", fmt.Errorf("waiting for qemu network namespace pid: %w", waitCtx.Err()) 742 case <-ticker.C: 743 } 744 } 745}