From e3750370df3be1413b1526668cbee60dc2a39f03 Mon Sep 17 00:00:00 2001
From: wxiaoguang <wxiaoguang@gmail.com>
Date: Sun, 30 Apr 2023 20:22:23 +0800
Subject: [PATCH] Use globally shared HTMLRender (#24436)

The old `HTMLRender` is not ideal.

1. It shouldn't be initialized multiple times, it consumes a lot of
memory and is slow.
2. It shouldn't depend on short-lived requests, the `WatchLocalChanges`
needs a long-running context.
3. It doesn't make sense to use FuncsMap slice.


HTMLRender was designed to only work for GItea's specialized 400+
templates, so it's good to make it a global shared instance.
---
 modules/context/context.go         |  2 +-
 modules/context/package.go         |  2 +-
 modules/templates/helper.go        | 10 +++-----
 modules/templates/htmlrenderer.go  | 37 +++++++++++++++++-------------
 modules/templates/mailer.go        |  7 +++---
 routers/api/v1/misc/markup_test.go |  2 +-
 routers/init.go                    |  2 +-
 routers/install/install.go         |  2 +-
 routers/install/routes.go          |  2 +-
 routers/web/base.go                |  2 +-
 routers/web/web.go                 |  3 ++-
 11 files changed, 37 insertions(+), 34 deletions(-)

diff --git a/modules/context/context.go b/modules/context/context.go
index 702da8a965..cd7fcebe55 100644
--- a/modules/context/context.go
+++ b/modules/context/context.go
@@ -677,7 +677,7 @@ func getCsrfOpts() CsrfOptions {
 
 // Contexter initializes a classic context for a request.
 func Contexter(ctx context.Context) func(next http.Handler) http.Handler {
-	_, rnd := templates.HTMLRenderer(ctx)
+	rnd := templates.HTMLRenderer()
 	csrfOpts := getCsrfOpts()
 	if !setting.IsProd {
 		CsrfTokenRegenerationInterval = 5 * time.Second // in dev, re-generate the tokens more aggressively for debug purpose
diff --git a/modules/context/package.go b/modules/context/package.go
index 2a0159eb5c..6c418b3164 100644
--- a/modules/context/package.go
+++ b/modules/context/package.go
@@ -131,7 +131,7 @@ func determineAccessMode(ctx *Context) (perm.AccessMode, error) {
 
 // PackageContexter initializes a package context for a request.
 func PackageContexter(ctx gocontext.Context) func(next http.Handler) http.Handler {
-	_, rnd := templates.HTMLRenderer(ctx)
+	rnd := templates.HTMLRenderer()
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
 			ctx := Context{
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index 20261eb959..4abd94d46e 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -10,7 +10,6 @@ import (
 	"html"
 	"html/template"
 	"net/url"
-	"regexp"
 	"strings"
 	"time"
 
@@ -26,12 +25,9 @@ import (
 	"code.gitea.io/gitea/services/gitdiff"
 )
 
-// Used from static.go && dynamic.go
-var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}[\s]*$`)
-
 // NewFuncMap returns functions for injecting to templates
-func NewFuncMap() []template.FuncMap {
-	return []template.FuncMap{map[string]interface{}{
+func NewFuncMap() template.FuncMap {
+	return map[string]interface{}{
 		"DumpVar": dumpVar,
 
 		// -----------------------------------------------------------------
@@ -192,7 +188,7 @@ func NewFuncMap() []template.FuncMap {
 
 		"FilenameIsImage": FilenameIsImage,
 		"TabSizeClass":    TabSizeClass,
-	}}
+	}
 }
 
 // Safe render raw as HTML
diff --git a/modules/templates/htmlrenderer.go b/modules/templates/htmlrenderer.go
index 2cecef5f84..d60be88727 100644
--- a/modules/templates/htmlrenderer.go
+++ b/modules/templates/htmlrenderer.go
@@ -6,7 +6,6 @@ package templates
 import (
 	"bufio"
 	"bytes"
-	"context"
 	"errors"
 	"fmt"
 	"io"
@@ -15,24 +14,29 @@ import (
 	"regexp"
 	"strconv"
 	"strings"
+	"sync"
 	"sync/atomic"
 	texttemplate "text/template"
 
 	"code.gitea.io/gitea/modules/assetfs"
+	"code.gitea.io/gitea/modules/graceful"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/templates/scopedtmpl"
 	"code.gitea.io/gitea/modules/util"
 )
 
-var rendererKey interface{} = "templatesHtmlRenderer"
-
 type TemplateExecutor scopedtmpl.TemplateExecutor
 
 type HTMLRender struct {
 	templates atomic.Pointer[scopedtmpl.ScopedTemplate]
 }
 
+var (
+	htmlRender     *HTMLRender
+	htmlRenderOnce sync.Once
+)
+
 var ErrTemplateNotInitialized = errors.New("template system is not initialized, check your log for errors")
 
 func (h *HTMLRender) HTML(w io.Writer, status int, name string, data interface{}) error {
@@ -55,14 +59,14 @@ func (h *HTMLRender) TemplateLookup(name string) (TemplateExecutor, error) {
 		return nil, ErrTemplateNotInitialized
 	}
 
-	return tmpls.Executor(name, NewFuncMap()[0])
+	return tmpls.Executor(name, NewFuncMap())
 }
 
 func (h *HTMLRender) CompileTemplates() error {
 	assets := AssetFS()
 	extSuffix := ".tmpl"
 	tmpls := scopedtmpl.NewScopedTemplate()
-	tmpls.Funcs(NewFuncMap()[0])
+	tmpls.Funcs(NewFuncMap())
 	files, err := ListWebTemplateAssetNames(assets)
 	if err != nil {
 		return nil
@@ -86,20 +90,21 @@ func (h *HTMLRender) CompileTemplates() error {
 	return nil
 }
 
-// HTMLRenderer returns the current html renderer for the context or creates and stores one within the context for future use
-func HTMLRenderer(ctx context.Context) (context.Context, *HTMLRender) {
-	if renderer, ok := ctx.Value(rendererKey).(*HTMLRender); ok {
-		return ctx, renderer
-	}
+// HTMLRenderer init once and returns the globally shared html renderer
+func HTMLRenderer() *HTMLRender {
+	htmlRenderOnce.Do(initHTMLRenderer)
+	return htmlRender
+}
 
+func initHTMLRenderer() {
 	rendererType := "static"
 	if !setting.IsProd {
 		rendererType = "auto-reloading"
 	}
-	log.Log(1, log.DEBUG, "Creating "+rendererType+" HTML Renderer")
+	log.Debug("Creating %s HTML Renderer", rendererType)
 
-	renderer := &HTMLRender{}
-	if err := renderer.CompileTemplates(); err != nil {
+	htmlRender = &HTMLRender{}
+	if err := htmlRender.CompileTemplates(); err != nil {
 		p := &templateErrorPrettier{assets: AssetFS()}
 		wrapFatal(p.handleFuncNotDefinedError(err))
 		wrapFatal(p.handleUnexpectedOperandError(err))
@@ -107,14 +112,14 @@ func HTMLRenderer(ctx context.Context) (context.Context, *HTMLRender) {
 		wrapFatal(p.handleGenericTemplateError(err))
 		log.Fatal("HTMLRenderer CompileTemplates error: %v", err)
 	}
+
 	if !setting.IsProd {
-		go AssetFS().WatchLocalChanges(ctx, func() {
-			if err := renderer.CompileTemplates(); err != nil {
+		go AssetFS().WatchLocalChanges(graceful.GetManager().ShutdownContext(), func() {
+			if err := htmlRender.CompileTemplates(); err != nil {
 				log.Error("Template error: %v\n%s", err, log.Stack(2))
 			}
 		})
 	}
-	return context.WithValue(ctx, rendererKey, renderer), renderer
 }
 
 func wrapFatal(msg string) {
diff --git a/modules/templates/mailer.go b/modules/templates/mailer.go
index 280ac0e587..ac1715fcd0 100644
--- a/modules/templates/mailer.go
+++ b/modules/templates/mailer.go
@@ -6,6 +6,7 @@ package templates
 import (
 	"context"
 	"html/template"
+	"regexp"
 	"strings"
 	texttmpl "text/template"
 
@@ -14,6 +15,8 @@ import (
 	"code.gitea.io/gitea/modules/setting"
 )
 
+var mailSubjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
+
 // mailSubjectTextFuncMap returns functions for injecting to text templates, it's only used for mail subject
 func mailSubjectTextFuncMap() texttmpl.FuncMap {
 	return texttmpl.FuncMap{
@@ -55,9 +58,7 @@ func Mailer(ctx context.Context) (*texttmpl.Template, *template.Template) {
 	bodyTemplates := template.New("")
 
 	subjectTemplates.Funcs(mailSubjectTextFuncMap())
-	for _, funcs := range NewFuncMap() {
-		bodyTemplates.Funcs(funcs)
-	}
+	bodyTemplates.Funcs(NewFuncMap())
 
 	assetFS := AssetFS()
 	refreshTemplates := func() {
diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go
index 301f51eea2..32de2956b7 100644
--- a/routers/api/v1/misc/markup_test.go
+++ b/routers/api/v1/misc/markup_test.go
@@ -30,7 +30,7 @@ const (
 )
 
 func createContext(req *http.Request) (*context.Context, *httptest.ResponseRecorder) {
-	_, rnd := templates.HTMLRenderer(req.Context())
+	rnd := templates.HTMLRenderer()
 	resp := httptest.NewRecorder()
 	c := &context.Context{
 		Req:    req,
diff --git a/routers/init.go b/routers/init.go
index 2c26bb5b07..358922b1ae 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -175,7 +175,7 @@ func GlobalInitInstalled(ctx context.Context) {
 
 // NormalRoutes represents non install routes
 func NormalRoutes(ctx context.Context) *web.Route {
-	ctx, _ = templates.HTMLRenderer(ctx)
+	_ = templates.HTMLRenderer()
 	r := web.NewRoute()
 	r.Use(common.ProtocolMiddlewares()...)
 
diff --git a/routers/install/install.go b/routers/install/install.go
index 8f8656230a..c838db6582 100644
--- a/routers/install/install.go
+++ b/routers/install/install.go
@@ -55,7 +55,7 @@ func getSupportedDbTypeNames() (dbTypeNames []map[string]string) {
 
 // Init prepare for rendering installation page
 func Init(ctx goctx.Context) func(next http.Handler) http.Handler {
-	_, rnd := templates.HTMLRenderer(ctx)
+	rnd := templates.HTMLRenderer()
 	dbTypeNames := getSupportedDbTypeNames()
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
diff --git a/routers/install/routes.go b/routers/install/routes.go
index 9c7420c59f..88c7e99695 100644
--- a/routers/install/routes.go
+++ b/routers/install/routes.go
@@ -66,7 +66,7 @@ func installRecovery(ctx goctx.Context) func(next http.Handler) http.Handler {
 					if !setting.IsProd {
 						store["ErrorMsg"] = combinedErr
 					}
-					_, rnd := templates.HTMLRenderer(ctx)
+					rnd := templates.HTMLRenderer()
 					err = rnd.HTML(w, http.StatusInternalServerError, "status/500", templates.BaseVars().Merge(store))
 					if err != nil {
 						log.Error("%v", err)
diff --git a/routers/web/base.go b/routers/web/base.go
index b5d7a737bf..73607cad06 100644
--- a/routers/web/base.go
+++ b/routers/web/base.go
@@ -120,7 +120,7 @@ func (d *dataStore) GetData() map[string]interface{} {
 // RecoveryWith500Page returns a middleware that recovers from any panics and writes a 500 and a log if so.
 // This error will be created with the gitea 500 page.
 func RecoveryWith500Page(ctx goctx.Context) func(next http.Handler) http.Handler {
-	_, rnd := templates.HTMLRenderer(ctx)
+	rnd := templates.HTMLRenderer()
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 			defer func() {
diff --git a/routers/web/web.go b/routers/web/web.go
index bb2442fec4..e63add51f3 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -114,7 +114,8 @@ func Routes(ctx gocontext.Context) *web.Route {
 	routes.RouteMethods("/apple-touch-icon.png", "GET, HEAD", misc.StaticRedirect("/assets/img/apple-touch-icon.png"))
 	routes.RouteMethods("/favicon.ico", "GET, HEAD", misc.StaticRedirect("/assets/img/favicon.png"))
 
-	ctx, _ = templates.HTMLRenderer(ctx)
+	_ = templates.HTMLRenderer()
+
 	common := []any{
 		common.Sessioner(),
 		RecoveryWith500Page(ctx),