1// Copyright 2022 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5package vcweb
6
7import (
8	"bufio"
9	"context"
10	"errors"
11	"io"
12	"log"
13	"net/http"
14	"net/http/httputil"
15	"net/url"
16	"os"
17	"os/exec"
18	"slices"
19	"strings"
20	"sync"
21	"time"
22)
23
24type hgHandler struct {
25	once      sync.Once
26	hgPath    string
27	hgPathErr error
28}
29
30func (h *hgHandler) Available() bool {
31	h.once.Do(func() {
32		h.hgPath, h.hgPathErr = exec.LookPath("hg")
33	})
34	return h.hgPathErr == nil
35}
36
37func (h *hgHandler) Handler(dir string, env []string, logger *log.Logger) (http.Handler, error) {
38	if !h.Available() {
39		return nil, ServerNotInstalledError{name: "hg"}
40	}
41
42	handler := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
43		// Mercurial has a CGI server implementation (called hgweb). In theory we
44		// could use that — however, assuming that hgweb is even installed, the
45		// configuration for hgweb varies by Python version (2 vs 3), and we would
46		// rather not go rooting around trying to find the right Python version to
47		// run.
48		//
49		// Instead, we'll take a somewhat more roundabout approach: we assume that
50		// if "hg" works at all then "hg serve" works too, and we'll execute that as
51		// a subprocess, using a reverse proxy to forward the request and response.
52
53		ctx, cancel := context.WithCancel(req.Context())
54		defer cancel()
55
56		cmd := exec.CommandContext(ctx, h.hgPath, "serve", "--port", "0", "--address", "localhost", "--accesslog", os.DevNull, "--name", "vcweb", "--print-url")
57		cmd.Dir = dir
58		cmd.Env = append(slices.Clip(env), "PWD="+dir)
59
60		cmd.Cancel = func() error {
61			err := cmd.Process.Signal(os.Interrupt)
62			if err != nil && !errors.Is(err, os.ErrProcessDone) {
63				err = cmd.Process.Kill()
64			}
65			return err
66		}
67		// This WaitDelay is arbitrary. After 'hg serve' prints its URL, any further
68		// I/O is only for debugging. (The actual output goes through the HTTP URL,
69		// not the standard I/O streams.)
70		cmd.WaitDelay = 10 * time.Second
71
72		stderr := new(strings.Builder)
73		cmd.Stderr = stderr
74
75		stdout, err := cmd.StdoutPipe()
76		if err != nil {
77			http.Error(w, err.Error(), http.StatusInternalServerError)
78			return
79		}
80
81		if err := cmd.Start(); err != nil {
82			http.Error(w, err.Error(), http.StatusInternalServerError)
83			return
84		}
85		var wg sync.WaitGroup
86		defer func() {
87			cancel()
88			err := cmd.Wait()
89			if out := strings.TrimSuffix(stderr.String(), "interrupted!\n"); out != "" {
90				logger.Printf("%v: %v\n%s", cmd, err, out)
91			} else {
92				logger.Printf("%v", cmd)
93			}
94			wg.Wait()
95		}()
96
97		r := bufio.NewReader(stdout)
98		line, err := r.ReadString('\n')
99		if err != nil {
100			return
101		}
102		// We have read what should be the server URL. 'hg serve' shouldn't need to
103		// write anything else to stdout, but it's not a big deal if it does anyway.
104		// Keep the stdout pipe open so that 'hg serve' won't get a SIGPIPE, but
105		// actively discard its output so that it won't hang on a blocking write.
106		wg.Add(1)
107		go func() {
108			io.Copy(io.Discard, r)
109			wg.Done()
110		}()
111
112		u, err := url.Parse(strings.TrimSpace(line))
113		if err != nil {
114			logger.Printf("%v: %v", cmd, err)
115			http.Error(w, err.Error(), http.StatusBadGateway)
116			return
117		}
118		logger.Printf("proxying hg request to %s", u)
119		httputil.NewSingleHostReverseProxy(u).ServeHTTP(w, req)
120	})
121
122	return handler, nil
123}
124