@ 261101.7

261101.2 · de64bda0325b4caac4af6191ee067279fdfc3a5a

web.go splits by purpose

web.go held forge URLs, cloning, the disk cache, listings and the pages of the web address. Each now has its own file: forge.go, webclone.go, webdisk.go, weblist.go and webpage.go. web.go keeps the web type, jobs, request dispatch and the helpers they share.

Declarations moved unchanged, as a sorted comparison of every line outside the import blocks confirms.

reading declarations

Changes to webclone.go. All files

webclone.go

new file mode 100644@@ -0,0 +1,312 @@+package main++import (+	"context"+	"errors"+	"fmt"+	"os"+	"path/filepath"+	"strings"+	"syscall"+	"time"+)++// probe asks every prefix of the path at once whether a repo answers+// there, each with and without .git, and takes the longest that does. It+// returns the repo's key and URL, or "" and whether it probed at all.+func (w *web) probe(j *webJob, segs []string) (key, cloneURL string, probed bool) {+	lo, hi := 2, min(len(segs), probeMax+1)+	if d, ok := depths[segs[0]]; ok {+		// The repo is the first d segments, so a probe would tell+		// nothing the clone does not, and costs a round trip.+		if len(segs) >= d {+			key = strings.Join(segs[:d], "/")+			w.step(j, segs[0]+" keeps every repo at owner/name, so "+webScheme+"://"+key+" needs no probe")+			return key, webScheme + "://" + key, true+		}+		lo, hi = d, min(d, len(segs))+	} else if d, ok := minDepths[segs[0]]; ok {+		lo = d+	}+	if lo > hi {+		return "", "", false+	}+	type answer struct {+		n   int+		url string+	}+	var urls []string+	for n := hi; n >= lo; n-- {+		u := webScheme + "://" + strings.Join(segs[:n], "/")+		urls = append(urls, u)+	}+	w.step(j, "git ls-remote, at once, on "+strings.Join(urls, " "))+	start := time.Now()+	ctx, cancel := context.WithTimeout(w.ctx, 30*time.Second)+	defer cancel()+	ch := make(chan answer)+	for n := hi; n >= lo; n-- {+		for _, u := range []string{webScheme + "://" + strings.Join(segs[:n], "/"), webScheme + "://" + strings.Join(segs[:n], "/") + ".git"} {+			go func() {+				if _, err := git(ctx, w.root, "ls-remote", u, "HEAD"); err == nil {+					ch <- answer{n, u}+				} else {+					ch <- answer{0, ""}+				}+			}()+		}+	}+	var best answer+	for range 2 * (hi - lo + 1) {+		if a := <-ch; a.n > best.n || a.n == best.n && a.n > 0 && len(a.url) < len(best.url) {+			best = a+		}+	}+	ms := time.Since(start).Milliseconds()+	if best.n == 0 {+		w.step(j, fmt.Sprintf("no repo answered, in %d ms", ms))+		return "", "", true+	}+	w.step(j, fmt.Sprintf("%s answered, in %d ms", best.url, ms))+	return strings.Join(segs[:best.n], "/"), best.url, true+}++// clone fetches the repo key from cloneURL into the cache and serves it.+func (w *web) clone(j *webJob, key, cloneURL string) error {+	w.mu.Lock()+	r := w.repos[key]+	w.mu.Unlock()+	if r != nil {+		return nil+	}+	dir := filepath.Join(w.root, filepath.FromSlash(key))+	if err := os.MkdirAll(dir, 0o700); err != nil {+		return err+	}+	unlock, err := w.lockRepo(key, dir)+	if err != nil {+		return err+	}+	defer unlock()+	// Another visit or server may have cloned it meanwhile.+	if r, err := w.load(key, true); r != nil || err != nil {+		return err+	}+	bare := filepath.Join(dir, ".git")+	if !exists(filepath.Join(bare, "HEAD")) {+		if w.slots != nil {+			select {+			case w.slots <- struct{}{}:+			default:+				w.step(j, fmt.Sprintf("waiting: %d clones run at once", cap(w.slots)))+				select {+				case w.slots <- struct{}{}:+				case <-w.ctx.Done():+					return w.ctx.Err()+				}+			}+			// The slot covers the worktree's blobs too, the larger fetch.+			defer func() { <-w.slots }()+		}+		tmp := bare + ".tmp"+		os.RemoveAll(tmp)+		w.step(j, "git clone --bare --filter=blob:none "+cloneURL+" "+w.where(bare))+		start := time.Now()+		ctx, stop := w.bounded(dir)+		_, err := gitProgress(ctx, dir, w.progress(j), "clone", "--bare", "--filter=blob:none", "--progress", "--", cloneURL, tmp)+		err = boundErr(ctx, err)+		stop()+		if err != nil {+			os.RemoveAll(tmp)+			return err+		}+		if err := os.Rename(tmp, bare); err != nil {+			return err+		}+		w.step(j, fmt.Sprintf("cloned in %s", seconds(time.Since(start))))+	}+	r = &webRepo{key: key, dir: dir}+	touch(filepath.Join(dir, ".fetched"))+	touch(filepath.Join(dir, ".used"))+	if _, err := w.checkout(r, w.progress(j), func(s string) { w.step(j, s) }); err != nil {+		return err+	}+	// The cache must hold the new repo before a page shows it.+	if err := w.trim(key); err != nil {+		return err+	}+	_, err = w.load(key, true)+	return err+}++// checkout builds the worktree of the commit HEAD names, unless it is+// there, and points .tree at it.+func (w *web) checkout(r *webRepo, progress func(string), step func(string)) (string, error) {+	bare := filepath.Join(r.dir, ".git")+	out, err := git(w.ctx, bare, "rev-parse", "--verify", "HEAD^{commit}")+	if err != nil {+		return "", err+	}+	head := strings.TrimSpace(string(out))+	name := ".wt-" + head[:12]+	wt := filepath.Join(r.dir, name)+	if !exists(wt) {+		step("git worktree add --detach " + w.where(wt) + " " + head[:12] + ", which fetches its files")+		start := time.Now()+		ctx, stop := w.bounded(r.dir)+		_, err := gitProgress(ctx, bare, progress, "worktree", "add", "--detach", "--", wt, head)+		err = boundErr(ctx, err)+		stop()+		if err != nil {+			os.RemoveAll(wt)+			git(w.ctx, bare, "worktree", "prune")+			return "", err+		}+		step(fmt.Sprintf("checked out in %s", seconds(time.Since(start))))+	}+	link := filepath.Join(r.dir, ".tree")+	os.Remove(link + ".new")+	if err := os.Symlink(name, link+".new"); err != nil {+		return "", err+	}+	return wt, os.Rename(link+".new", link)+}++// refresh fetches r again, and serves the new commit when HEAD moved. A+// failed fetch leaves the commit served, and its pages say so.+func (w *web) refresh(r *webRepo) {+	failed := ""+	defer func() {+		r.mu.Lock()+		r.fetching, r.failed = false, failed+		if failed == "" {+			r.fetched = time.Now()+		}+		r.mu.Unlock()+		w.kick(true)+	}()+	w.kick(true)+	unlock, err := w.lockRepo(r.key, r.dir)+	if err != nil {+		failed = err.Error()+		return+	}+	defer unlock()+	bare := filepath.Join(r.dir, ".git")+	start := time.Now()+	ctx, stop := w.bounded(r.dir)+	_, err = git(ctx, bare, "fetch", "--prune", "--no-write-fetch-head", "origin",+		"+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")+	err = boundErr(ctx, err)+	stop()+	if err != nil {+		failed = err.Error()+		logErr("E%d: cannot fetch %s: %s", syscall.EHOSTUNREACH, r.key, failed)+		return+	}+	touch(filepath.Join(r.dir, ".fetched"))+	r.mu.Lock()+	old := r.tree.dir+	r.mu.Unlock()+	wt, err := w.checkout(r, nil, func(string) {})+	if err != nil {+		failed = err.Error()+		return+	}+	logInfo("fetched %s in %d ms", r.key, time.Since(start).Milliseconds())+	if wt == old {+		return+	}+	if err := w.show(r, wt); err != nil {+		failed = err.Error()+		return+	}+	w.dropTree(r, old)+	if err := w.trim(r.key); err != nil {+		failed = err.Error()+	}+}++// fetchWait bounds a public server's clone, fetch or checkout, so a forge+// that sends slowly cannot hold a clone's place for good.+const fetchWait = 10 * time.Minute++// bounded is the context for git fetching into the repo directory dir.+// On a public server it ends after fetchWait, or once dir outgrows the+// cache's limit: trim runs only after a fetch, and a stranger's forge may+// send without end.+func (w *web) bounded(dir string) (context.Context, func()) {+	if !w.public {+		return w.ctx, func() {}+	}+	ctx, cancel := context.WithCancelCause(w.ctx)+	ctx, cancelWait := context.WithTimeoutCause(ctx, fetchWait,+		&errno{syscall.ETIMEDOUT, fmt.Sprintf("the forge took over %d minutes, the most a public server waits", int(fetchWait.Minutes()))})+	done := make(chan struct{})+	w.wg.Go(func() {+		tick := time.NewTicker(2 * time.Second)+		defer tick.Stop()+		for {+			select {+			case <-done:+				return+			case <-tick.C:+				if n := diskUse(dir); n > w.limit {+					cancel(&errno{syscall.EFBIG, fmt.Sprintf("the repo grew past %s, the cache's limit, so the fetch stopped; --disk sets the limit", sizeText(w.limit))})+					return+				}+			}+		}+	})+	return ctx, func() {+		close(done)+		cancelWait()+		cancel(nil)+	}+}++// boundErr is why git failed in a context from bounded: the limit it hit,+// or err.+func boundErr(ctx context.Context, err error) error {+	if err != nil && ctx.Err() != nil {+		if cause := context.Cause(ctx); cause != nil {+			var e *errno+			if errors.As(cause, &e) {+				return cause+			}+		}+	}+	return err+}++// lockRepo marks the repo key busy and takes its lock.+func (w *web) lockRepo(key, dir string) (func(), error) {+	w.mu.Lock()+	w.busy[key] = true+	w.mu.Unlock()+	unlock, err := flockDir(dir, syscall.LOCK_EX)+	if err != nil {+		unlock = func() {}+	}+	return func() {+		unlock()+		w.mu.Lock()+		delete(w.busy, key)+		w.mu.Unlock()+	}, err+}++// flockDir takes the repo directory's lock, which keeps two servers from+// fetching one repo at once, and one from deleting a repo another fetches.+// how is LOCK_EX, or LOCK_EX|LOCK_NB to fail with EWOULDBLOCK when held.+func flockDir(dir string, how int) (func(), error) {+	f, err := os.OpenFile(filepath.Join(dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o600)+	if err != nil {+		return nil, err+	}+	if err := flock(f, how); err != nil {+		f.Close()+		return nil, err+	}+	return func() { f.Close() }, nil+}