Monorepo for Tangled
tangled.org
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}