Repository: gotenberg/gotenberg (go)

Issue:
Performance of Libreoffice PDF generation
We are currently using a Libreoffice + unoconv + api (node) architecture to generate our PDF, and we are considering switching to the more recent, maintained and beautifully coded Gotenberg ;) but we noticed some performance drawbacks.

The doc says `It starts a dedicated LibreOffice instance for each request.`.
This seems to add about 2 to 3s to the generation time, vs. our current architecture where the Libreoffice instance is just constantly idling, waiting for a new request (unoconv in listener mode).

3 additional seconds per conversion feels quite like a long time =/ Is there any way to improve that? Do you envision an option in the future to keep the LibreOffice open for longer than one request? 

Also, it says `It starts a dedicated LibreOffice instance for each REQUEST` but according to our preliminary tests, it seems like it does it for every conversion, not every request : if we make a request with multiple files to convert (and merge), the conversion time seems to just be linear with the number of docs (vs. our expectation of faster conversion for subsequent files once the LibreOffice instance is started for the first one). Do you confirm that's what is happening?

Pull request:
feat: start a unoconv listener
## New Features

* 🚀 Now starts a `unoconv` listener for **substantial** performance improvements for the LibreOffice module / the unoconv PDF engine (**new default**) - closes #373.
* New property `--unoconv-disable-listener` - do not start a unoconv listener - save resources in detriment of performance.
* New metric `{namespace}_unoconv_listener_active_instances_count` - current number of active unoconv listener instances.
* New metric `{namespace}_unoconv_listener_queue_length` - current number of processes in the queue.


## Misc

* Improves the shutdown process.

## Chore

* Updates Go dependencies.

Patch:
diff --git a/Makefile b/Makefile
index 7a35943..c1e372a 100644
--- a/Makefile
+++ b/Makefile
@@ -53,6 +53,7 @@ PROMETHEUS_NAMESPACE=gotenberg
 PROMETHEUS_COLLECT_INTERVAL=1s
 PROMETHEUS_DISABLE_ROUTE_LOGGING=false
 PROMETHEUS_DISABLE_COLLECT=false
+UNOCONV_DISABLE_LISTENER=false
 WEBHOOK_ALLOW_LIST=
 WEBHOOK_DENY_LIST=
 WEBHOOK_ERROR_ALLOW_LIST=
@@ -95,6 +96,7 @@ run: ## Start a Gotenberg container
 	--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
 	--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
 	--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
+	--unoconv-disable-listener=$(UNOCONV_DISABLE_LISTENER) \
 	--webhook-allow-list=$(WEBHOOK_ALLOW_LIST) \
 	--webhook-deny-list=$(WEBHOOK_DENY_LIST) \
 	--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
diff --git a/cmd/gotenberg.go b/cmd/gotenberg.go
index bdbd079..be40733 100644
--- a/cmd/gotenberg.go
+++ b/cmd/gotenberg.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
 	flag "github.com/spf13/pflag"
+	"golang.org/x/sync/errgroup"
 )
 
 // See https://patorjk.com/software/taag/#p=display&f=Small%20Slant&t=Gotenberg.
@@ -117,18 +118,40 @@ func Run() {
 	gracefulShutdownCtx, cancel := context.WithTimeout(context.Background(), gracefulShutdownDuration)
 	defer cancel()
 
+	forceQuit := make(chan os.Signal, 1)
+	signal.Notify(forceQuit, os.Interrupt)
+
+	go func() {
+		// In case of force quit, cancel the context.
+		<-forceQuit
+		cancel()
+	}()
+
 	fmt.Printf("[SYSTEM] graceful shutdown of %s\n", gracefulShutdownDuration)
 
+	eg, _ := errgroup.WithContext(gracefulShutdownCtx)
+
 	for _, a := range apps {
-		id := a.(gotenberg.Module).Descriptor().ID
-		app := a.(gotenberg.App)
+		eg.Go(func(app gotenberg.App) func() error {
+			return func() error {
+				id := app.(gotenberg.Module).Descriptor().ID
 
-		err = app.Stop(gracefulShutdownCtx)
-		if err != nil {
-			fmt.Printf("[ERROR] stopping %s: %s\n", id, err)
-		}
+				err = app.Stop(gracefulShutdownCtx)
+				if err != nil {
+					return fmt.Errorf("stopping %s: %w", id, err)
+				}
 
-		fmt.Printf("[SYSTEM] %s: application stopped\n", id)
+				fmt.Printf("[SYSTEM] %s: application stopped\n", id)
+
+				return nil
+			}
+		}(a.(gotenberg.App)))
+	}
+
+	err = eg.Wait()
+	if err != nil {
+		fmt.Printf("[FATAL] %v\n", err)
+		os.Exit(1)
 	}
 
 	os.Exit(0)
diff --git a/go.mod b/go.mod
index 7e417bb..9e4158a 100644
--- a/go.mod
+++ b/go.mod
@@ -5,8 +5,8 @@ go 1.17
 require (
 	github.com/alexliesenfeld/health v0.6.0
 	github.com/andybalholm/brotli v1.0.4 // indirect
-	github.com/chromedp/cdproto v0.0.0-20211112223831-e508c7bf6446
-	github.com/chromedp/chromedp v0.7.4
+	github.com/chromedp/cdproto v0.0.0-20211130100048-e5358edf9020
+	github.com/chromedp/chromedp v0.7.6
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/uuid v1.3.0
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -20,7 +20,7 @@ require (
 	github.com/microcosm-cc/bluemonday v1.0.16
 	github.com/nwaples/rardecode v1.1.2 // indirect
 	github.com/pdfcpu/pdfcpu v0.3.12
-	github.com/pierrec/lz4/v4 v4.1.11 // indirect
+	github.com/pierrec/lz4/v4 v4.1.12 // indirect
 	github.com/prometheus/client_golang v1.11.0
 	github.com/russross/blackfriday/v2 v2.1.0
 	github.com/spf13/pflag v1.0.5
@@ -32,7 +32,7 @@ require (
 	golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
 	golang.org/x/net v0.0.0-20210913180222-943fd674d43e
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
-	golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
+	golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 // indirect
 	golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
 	golang.org/x/text v0.3.7
 )
diff --git a/go.sum b/go.sum
index 75a0872..52ef4e3 100644
--- a/go.sum
+++ b/go.sum
@@ -55,11 +55,11 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
 github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chromedp/cdproto v0.0.0-20210713064928-7d28b402946a/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
-github.com/chromedp/cdproto v0.0.0-20211112223831-e508c7bf6446 h1:uG7ssld4pEDC5O+1UoKqMb0djjJb8qjuKZVnCskGY+s=
-github.com/chromedp/cdproto v0.0.0-20211112223831-e508c7bf6446/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
-github.com/chromedp/chromedp v0.7.4 h1:U+0d3WbB/Oj4mDuBOI0P7S3PJEued5UZIl5AJ3QulwU=
-github.com/chromedp/chromedp v0.7.4/go.mod h1:dBj+SXuQHznp6ZPwZeDDEBZKwclUwDLbZ0hjMialMYs=
+github.com/chromedp/cdproto v0.0.0-20211126220118-81fa0469ad77/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
+github.com/chromedp/cdproto v0.0.0-20211130100048-e5358edf9020 h1:ajL+1oBLd36180hQvcAerLsLynmFOLCylLVbiyXTCLk=
+github.com/chromedp/cdproto v0.0.0-20211130100048-e5358edf9020/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
+github.com/chromedp/chromedp v0.7.6 h1:2juGaktzjwULlsn+DnvIZXFUckEp5xs+GOBroaea+jA=
+github.com/chromedp/chromedp v0.7.6/go.mod h1:ayT4YU/MGAALNfOg9gNrpGSAdnU51PMx+FCeuT1iXzo=
 github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
 github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -233,8 +233,8 @@ github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzb
 github.com/pdfcpu/pdfcpu v0.3.12 h1:B+MdKisilWNSk5OCO58Z9U6H93usH73xqk6hMOaZCls=
 github.com/pdfcpu/pdfcpu v0.3.12/go.mod h1:8XVBtVxuuIuSZL4Ez15Q4QoC+H8zeAaGnuiOEwAk8jA=
 github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
-github.com/pierrec/lz4/v4 v4.1.11 h1:LVs17FAZJFOjgmJXl9Tf13WfLUvZq7/RjfEJrnwZ9OE=
-github.com/pierrec/lz4/v4 v4.1.11/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/pierrec/lz4/v4 v4.1.12 h1:44l88ehTZAUGW4VlO1QC4zkilL99M6Y9MXNwEs0uzP8=
+github.com/pierrec/lz4/v4 v4.1.12/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -442,14 +442,14 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
 golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211124211545-fe61309f8881 h1:TyHqChC80pFkXWraUUf6RuB5IqFdQieMLwwCJokV2pc=
+golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
 golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
diff --git a/pkg/modules/gc/gc.go b/pkg/modules/gc/gc.go
index f3f37e8..75f641e 100644
--- a/pkg/modules/gc/gc.go
+++ b/pkg/modules/gc/gc.go
@@ -5,7 +5,6 @@ import (
 	"errors"
 	"fmt"
 	"os"
-	"os/signal"
 	"path/filepath"
 	"strings"
 	"sync"
@@ -202,19 +201,10 @@ func (gc *GarbageCollector) Stop(ctx context.Context) error {
 	}
 
 	// Block until the context is done so that other module may gracefully stop
-	// before we do a shutdown cleanup. We skip this step if we receive a
-	// SIGINT in the meantime.
+	// before we do a shutdown cleanup.
 	gc.logger.Debug("wait for the end of grace duration")
 
-	quit := make(chan os.Signal, 1)
-	signal.Notify(quit, os.Interrupt)
-
-	select {
-	case <-quit:
-		return nil
-	case <-ctx.Done():
-		break
-	}
+	<-ctx.Done()
 
 	gc.ticker.Stop()
 	gc.done <- true
diff --git a/pkg/modules/libreoffice/unoconv/freeport.go b/pkg/modules/libreoffice/unoconv/freeport.go
new file mode 100644
index 0000000..c926f78
--- /dev/null
+++ b/pkg/modules/libreoffice/unoconv/freeport.go
@@ -0,0 +1,31 @@
+package unoconv
+
+import (
+	"fmt"
+	"net"
+	"strconv"
+
+	"go.uber.org/zap"
+)
+
+func freePort(logger *zap.Logger) (int, error) {
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		return 0, fmt.Errorf("listen on the local network address: %w", err)
+	}
+	defer func() {
+		err := listener.Close()
+		if err != nil {
+			logger.Error(fmt.Sprintf("close listener: %s", err.Error()))
+		}
+	}()
+
+	addr := listener.Addr().String()
+
+	_, portStr, err := net.SplitHostPort(addr)
+	if err != nil {
+		return 0, fmt.Errorf("get free port from host: %w", err)
+	}
+
+	return strconv.Atoi(portStr)
+}
diff --git a/pkg/modules/libreoffice/unoconv/unoconv.go b/pkg/modules/libreoffice/unoconv/unoconv.go
index 8bb4a24..f8b9e84 100644
--- a/pkg/modules/libreoffice/unoconv/unoconv.go
+++ b/pkg/modules/libreoffice/unoconv/unoconv.go
@@ -4,13 +4,12 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"net"
 	"os"
-	"strconv"
 	"strings"
 	"sync"
 
 	"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
+	flag "github.com/spf13/pflag"
 	"go.uber.org/zap"
 )
 
@@ -24,7 +23,12 @@ var ErrMalformedPageRanges = errors.New("page ranges are malformed")
 
 // Unoconv is a module which provides an API to interact with unoconv.
 type Unoconv struct {
-	binPath string
+	binPath         string
+	disableListener bool
+
+	listenerCmd  gotenberg.Cmd
+	listenerPort int
+	logger       *zap.Logger
 }
 
 // Options gathers available options when converting a document to PDF.
@@ -67,14 +71,23 @@ type Provider interface {
 // Descriptor returns a Unoconv's module descriptor.
 func (Unoconv) Descriptor() gotenberg.ModuleDescriptor {
 	return gotenberg.ModuleDescriptor{
-		ID:  "unoconv",
+		ID: "unoconv",
+		FlagSet: func() *flag.FlagSet {
+			fs := flag.NewFlagSet("unoconv", flag.ExitOnError)
+			fs.Bool("unoconv-disable-listener", false, "Do not start a unoconv listener - save resources in detriment of performance")
+
+			return fs
+		}(),
 		New: func() gotenberg.Module { return new(Unoconv) },
 	}
 }
 
 // Provision sets the module properties. It returns an error if the environment
 // variable UNOCONV_BIN_PATH is not set.
-func (mod *Unoconv) Provision(_ *gotenberg.Context) error {
+func (mod *Unoconv) Provision(ctx *gotenberg.Context) error {
+	flags := ctx.ParsedFlags()
+	mod.disableListener = flags.MustBool("unoconv-disable-listener")
+
 	binPath, ok := os.LookupEnv("UNOCONV_BIN_PATH")
 	if !ok {
 		return errors.New("UNOCONV_BIN_PATH environment variable is not set")
@@ -82,6 +95,18 @@ func (mod *Unoconv) Provision(_ *gotenberg.Context) error {
 
 	mod.binPath = binPath
 
+	loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
+	if err != nil {
+		return fmt.Errorf("get logger provider: %w", err)
+	}
+
+	logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod)
+	if err != nil {
+		return fmt.Errorf("get logger: %w", err)
+	}
+
+	mod.logger = logger
+
 	return nil
 }
 
@@ -95,12 +120,92 @@ func (mod Unoconv) Validate() error {
 	return nil
 }
 
+func (mod *Unoconv) Start() error {
+	if mod.disableListener {
+		return nil
+	}
+
+	port, err := freePort(mod.logger)
+	if err != nil {
+		return fmt.Errorf("get free port: %w", err)
+	}
+
+	mod.listenerPort = port
+
+	args := []string{
+		"--listener",
+		"--user-profile",
+		// Just to make sure LibreOffice does not leak files in an unknown
+		// directory. The directory will be removed anyway by the garbage
+		// collector.
+		fmt.Sprintf("//%s", gotenberg.NewDirPath()),
+		"--port",
+		fmt.Sprintf("%d", mod.listenerPort),
+	}
+
+	checkedEntry := mod.logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
+	if checkedEntry != nil {
+		args = append(args, "-vvv")
+	}
+
+	mod.listenerCmd = gotenberg.Command(mod.logger, mod.binPath, args...)
+
+	err = mod.listenerCmd.Start()
+	if err != nil {
+		return fmt.Errorf("start unoconv listener: %w", err)
+	}
+
+	listenerActiveInstancesCountMu.Lock()
+	listenerActiveInstancesCount += 1
+	listenerActiveInstancesCountMu.Unlock()
+
+	return nil
+}
+
+// StartupMessage returns a custom startup message.
+func (mod Unoconv) StartupMessage() string {
+	if mod.disableListener {
+		return "listener disabled"
+	}
+
+	return fmt.Sprintf("listener started on port %d", mod.listenerPort)
+}
+
+// Stop stops the HTTP server.
+func (mod *Unoconv) Stop(ctx context.Context) error {
+	if mod.disableListener {
+		return nil
+	}
+
+	_, ok := ctx.Deadline()
+	if !ok {
+		return errors.New("no context dead line")
+	}
+
+	// Block until the context is done so that other module may gracefully stop
+	// before we do a shutdown cleanup.
+	mod.logger.Debug("wait for the end of grace duration")
+
+	<-ctx.Done()
+
+	err := mod.listenerCmd.Kill()
+	if err != nil {
+		return fmt.Errorf("kill unoconv listener: %w", err)
+	}
+
+	listenerActiveInstancesCountMu.Lock()
+	listenerActiveInstancesCount -= 1
+	listenerActiveInstancesCountMu.Unlock()
+
+	return nil
+}
+
 // Metrics returns the metrics.
 func (mod Unoconv) Metrics() ([]gotenberg.Metric, error) {
 	return []gotenberg.Metric{
 		{
 			Name:        "unoconv_active_instances_count",
-			Description: "Current number of active LibreOffice instances.",
+			Description: "Current number of active unoconv instances.",
 			Read: func() float64 {
 				activeInstancesCountMu.RLock()
 				defer activeInstancesCountMu.RUnlock()
@@ -108,55 +213,68 @@ func (mod Unoconv) Metrics() ([]gotenberg.Metric, error) {
 				return activeInstancesCount
 			},
 		},
+		{
+			Name:        "unoconv_listener_active_instances_count",
+			Description: "Current number of active unoconv listener instances.",
+			Read: func() float64 {
+				listenerActiveInstancesCountMu.RLock()
+				defer listenerActiveInstancesCountMu.RUnlock()
+
+				return listenerActiveInstancesCount
+			},
+		},
+		{
+			Name:        "unoconv_listener_queue_length",
+			Description: "Current number of processes in the queue.",
+			Read: func() float64 {
+				listenerQueueLengthMu.RLock()
+				defer listenerQueueLengthMu.RUnlock()
+
+				return listenerQueueLength
+			},
+		},
 	}, nil
 }
 
 // Unoconv returns an API for interacting with unoconv.
-func (mod Unoconv) Unoconv() (API, error) {
+func (mod *Unoconv) Unoconv() (API, error) {
 	return mod, nil
 }
 
-// PDF converts a document to PDF. It creates a dedicated LibreOffice instance
-// thanks to a custom user profile directory and a free port. Substantial calls
-// to this method may increase CPU and memory usage drastically. In such a
-// scenario, the given context may also be done before the end of the
-// conversion.
+// PDF converts a document to PDF.
+//
+// In stateless mode, it creates a dedicated LibreOffice instance thanks to a
+// custom user profile directory and a free port. Substantial calls to this
+// method may increase CPU and memory usage drastically. In such a scenario,
+// the given context may also be done before the end of the conversion.
+//
+// In listener mode, it calls the unoconv listener to interact with
+// LibreOffice, improving substantially the performance. However, it cannot
+// perform parallel operations and have to wait for the lock to be available.
 func (mod Unoconv) PDF(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
-	port, err := func() (int, error) {
-		listener, err := net.Listen("tcp", "127.0.0.1:0")
-		if err != nil {
-			return 0, fmt.Errorf("listen on the local network address: %w", err)
-		}
-		defer func() {
-			err := listener.Close()
-			if err != nil {
-				logger.Error(fmt.Sprintf("close listener: %s", err.Error()))
-			}
-		}()
+	args := []string{
+		"--format",
+		"pdf",
+	}
 
-		addr := listener.Addr().String()
+	var userProfileDirPath string
 
-		_, portStr, err := net.SplitHostPort(addr)
+	if mod.disableListener {
+		port, err := freePort(logger)
 		if err != nil {
-			return 0, fmt.Errorf("get free port from host: %w", err)
+			return fmt.Errorf("get free port: %w", err)
 		}
 
-		return strconv.Atoi(portStr)
-	}()
+		userProfileDirPath = gotenberg.NewDirPath()
 
-	if err != nil {
-		return fmt.Errorf("get free port: %w", err)
-	}
-
-	userProfileDirPath := gotenberg.NewDirPath()
-
-	args := []string{
-		"--user-profile",
-		fmt.Sprintf("//%s", userProfileDirPath),
-		"--port",
-		fmt.Sprintf("%d", port),
-		"--format",
-		"pdf",
+		args = append(args,
+			"--port",
+			fmt.Sprintf("%d", port),
+			"--user-profile",
+			fmt.Sprintf("//%s", userProfileDirPath),
+		)
+	} else {
+		args = append(args, "--port", fmt.Sprintf("%d", mod.listenerPort))
 	}
 
 	checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
@@ -178,6 +296,36 @@ func (mod Unoconv) PDF(ctx context.Context, logger *zap.Logger, inputPath, outpu
 
 	args = append(args, "--output", outputPath, inputPath)
 
+	if !mod.disableListener {
+		listenerQueueLengthMu.Lock()
+		listenerQueueLength += 1
+		listenerQueueLengthMu.Unlock()
+
+		select {
+		case listenerLock <- struct{}{}:
+			logger.Debug("unoconv lock acquired")
+
+			listenerQueueLengthMu.Lock()
+			listenerQueueLength -= 1
+			listenerQueueLengthMu.Unlock()
+
+			break
+		case <-ctx.Done():
+			logger.Debug("failed to acquire the unoconv lock before deadline")
+
+			listenerQueueLengthMu.Lock()
+			listenerQueueLength -= 1
+			listenerQueueLengthMu.Unlock()
+
+			return fmt.Errorf("acquire unoconv lock: %w", ctx.Err())
+		}
+
+		defer func() {
+			<-listenerLock
+			logger.Debug("unoconv lock released")
+		}()
+	}
+
 	cmd, err := gotenberg.CommandContext(ctx, logger, mod.binPath, args...)
 	if err != nil {
 		return fmt.Errorf("create unoconv command: %w", err)
@@ -195,16 +343,18 @@ func (mod Unoconv) PDF(ctx context.Context, logger *zap.Logger, inputPath, outpu
 	activeInstancesCount -= 1
 	activeInstancesCountMu.Unlock()
 
-	// Always remove the user profile directory created by LibreOffice.
-	// See https://github.com/gotenberg/gotenberg/issues/192.
-	go func() {
-		logger.Debug(fmt.Sprintf("remove user profile directory '%s'", userProfileDirPath))
+	if mod.disableListener {
+		// Always remove the user profile directory created by LibreOffice.
+		// See https://github.com/gotenberg/gotenberg/issues/192.
+		go func() {
+			logger.Debug(fmt.Sprintf("remove user profile directory '%s'", userProfileDirPath))
 
-		err := os.RemoveAll(userProfileDirPath)
-		if err != nil {
-			logger.Error(fmt.Sprintf("remove user profile directory: %s", err))
-		}
-	}()
+			err := os.RemoveAll(userProfileDirPath)
+			if err != nil {
+				logger.Error(fmt.Sprintf("remove user profile directory: %s", err))
+			}
+		}()
+	}
 
 	if err == nil {
 		return nil
@@ -311,8 +461,13 @@ func (mod Unoconv) Extensions() []string {
 }
 
 var (
-	activeInstancesCount   float64
-	activeInstancesCountMu sync.RWMutex
+	listenerLock                   = make(chan struct{}, 1)
+	listenerQueueLength            float64
+	listenerQueueLengthMu          sync.RWMutex
+	listenerActiveInstancesCount   float64
+	listenerActiveInstancesCountMu sync.RWMutex
+	activeInstancesCount           float64
+	activeInstancesCountMu         sync.RWMutex
 )
 
 // Interface guards.
@@ -320,6 +475,7 @@ var (
 	_ gotenberg.Module          = (*Unoconv)(nil)
 	_ gotenberg.Provisioner     = (*Unoconv)(nil)
 	_ gotenberg.Validator       = (*Unoconv)(nil)
+	_ gotenberg.App             = (*Unoconv)(nil)
 	_ gotenberg.MetricsProvider = (*Unoconv)(nil)
 	_ API                       = (*Unoconv)(nil)
 	_ Provider                  = (*Unoconv)(nil)
diff --git a/pkg/modules/prometheus/prometheus.go b/pkg/modules/prometheus/prometheus.go
index 897e775..ba6b268 100644
--- a/pkg/modules/prometheus/prometheus.go
+++ b/pkg/modules/prometheus/prometheus.go
@@ -150,7 +150,7 @@ func (mod Prometheus) Start() error {
 // StartupMessage returns a custom startup message.
 func (mod Prometheus) StartupMessage() string {
 	if mod.disableCollect {
-		return "application not started (collect disabled by user)"
+		return "collect disabled"
 	}
 
 	return "collecting metrics"


Test patch:
diff --git a/pkg/modules/chromium/chromium_test.go b/pkg/modules/chromium/chromium_test.go
index 7756099..2990abc 100644
--- a/pkg/modules/chromium/chromium_test.go
+++ b/pkg/modules/chromium/chromium_test.go
@@ -176,11 +176,11 @@ func TestChromium_Validate(t *testing.T) {
 func TestChromium_Metrics(t *testing.T) {
 	metrics, err := new(Chromium).Metrics()
 	if err != nil {
-		t.Errorf("expected no error but got: %v", err)
+		t.Fatalf("expected no error but got: %v", err)
 	}
 
 	if len(metrics) != 1 {
-		t.Errorf("expected %d metrics, but got %d", 1, len(metrics))
+		t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
 	}
 
 	actual := metrics[0].Read()
diff --git a/pkg/modules/libreoffice/unoconv/unoconv_test.go b/pkg/modules/libreoffice/unoconv/unoconv_test.go
index 6395c66..3e5df02 100644
--- a/pkg/modules/libreoffice/unoconv/unoconv_test.go
+++ b/pkg/modules/libreoffice/unoconv/unoconv_test.go
@@ -2,14 +2,42 @@ package unoconv
 
 import (
 	"context"
+	"errors"
 	"os"
 	"reflect"
 	"testing"
+	"time"
 
 	"github.com/gotenberg/gotenberg/v7/pkg/gotenberg"
 	"go.uber.org/zap"
 )
 
+type ProtoModule struct {
+	descriptor func() gotenberg.ModuleDescriptor
+}
+
+func (mod ProtoModule) Descriptor() gotenberg.ModuleDescriptor {
+	return mod.descriptor()
+}
+
+type ProtoValidator struct {
+	ProtoModule
+	validate func() error
+}
+
+func (mod ProtoValidator) Validate() error {
+	return mod.validate()
+}
+
+type ProtoLoggerProvider struct {
+	ProtoModule
+	logger func(mod gotenberg.Module) (*zap.Logger, error)
+}
+
+func (factory ProtoLoggerProvider) Logger(mod gotenberg.Module) (*zap.Logger, error) {
+	return factory.logger(mod)
+}
+
 func TestUnoconv_Descriptor(t *testing.T) {
 	descriptor := Unoconv{}.Descriptor()
 
@@ -22,12 +50,69 @@ func TestUnoconv_Descriptor(t *testing.T) {
 }
 
 func TestUnoconv_Provision(t *testing.T) {
-	mod := new(Unoconv)
-	ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil)
+	for i, tc := range []struct {
+		ctx       *gotenberg.Context
+		expectErr bool
+	}{
+		{
+			ctx: gotenberg.NewContext(
+				gotenberg.ParsedFlags{FlagSet: new(Unoconv).Descriptor().FlagSet},
+				make([]gotenberg.ModuleDescriptor, 0),
+			),
+			expectErr: true,
+		},
+		{
+			ctx: func() *gotenberg.Context {
+				mod := struct {
+					ProtoLoggerProvider
+				}{}
+				mod.descriptor = func() gotenberg.ModuleDescriptor {
+					return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
+				}
+				mod.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return nil, errors.New("foo") }
 
-	err := mod.Provision(ctx)
-	if err != nil {
-		t.Errorf("expected no error but got: %v", err)
+				return gotenberg.NewContext(
+					gotenberg.ParsedFlags{
+						FlagSet: new(Unoconv).Descriptor().FlagSet,
+					},
+					[]gotenberg.ModuleDescriptor{
+						mod.Descriptor(),
+					},
+				)
+			}(),
+			expectErr: true,
+		},
+		{
+			ctx: func() *gotenberg.Context {
+				mod := struct {
+					ProtoLoggerProvider
+				}{}
+				mod.descriptor = func() gotenberg.ModuleDescriptor {
+					return gotenberg.ModuleDescriptor{ID: "foo", New: func() gotenberg.Module { return mod }}
+				}
+				mod.logger = func(mod gotenberg.Module) (*zap.Logger, error) { return zap.NewNop(), nil }
+
+				return gotenberg.NewContext(
+					gotenberg.ParsedFlags{
+						FlagSet: new(Unoconv).Descriptor().FlagSet,
+					},
+					[]gotenberg.ModuleDescriptor{
+						mod.Descriptor(),
+					},
+				)
+			}(),
+		},
+	} {
+		mod := new(Unoconv)
+		err := mod.Provision(tc.ctx)
+
+		if tc.expectErr && err == nil {
+			t.Errorf("test %d: expected error but got: %v", i, err)
+		}
+
+		if !tc.expectErr && err != nil {
+			t.Errorf("test %d: expected no error but got: %v", i, err)
+		}
 	}
 }
 
@@ -47,8 +132,10 @@ func TestUnoconv_Validate(t *testing.T) {
 			binPath: os.Getenv("UNOCONV_BIN_PATH"),
 		},
 	} {
-		mod := new(Unoconv)
-		mod.binPath = tc.binPath
+		mod := Unoconv{
+			binPath: tc.binPath,
+		}
+
 		err := mod.Validate()
 
 		if tc.expectErr && err == nil {
@@ -61,20 +148,149 @@ func TestUnoconv_Validate(t *testing.T) {
 	}
 }
 
-func TestChromium_Metrics(t *testing.T) {
+func TestUnoconv_Start(t *testing.T) {
+	for i, tc := range []struct {
+		mod       *Unoconv
+		expectErr bool
+	}{
+		{
+			mod: &Unoconv{
+				disableListener: true,
+				logger:          zap.NewNop(),
+			},
+		},
+		{
+			mod: &Unoconv{
+				binPath:         os.Getenv("UNOCONV_BIN_PATH"),
+				disableListener: false,
+				logger:          zap.NewExample(),
+			},
+		},
+	} {
+		func() {
+			err := tc.mod.Start()
+
+			if tc.expectErr && err == nil {
+				t.Errorf("test %d: expected error but got: %v", i, err)
+			}
+
+			if !tc.expectErr && err != nil {
+				t.Errorf("test %d: expected no error but got: %v", i, err)
+			}
+
+			ctx, cancel := context.WithTimeout(context.Background(), time.Duration(1)*time.Nanosecond)
+			defer cancel()
+
+			err = tc.mod.Stop(ctx)
+			if err != nil {
+				t.Errorf("test %d: expected not error but got: %v", i, err)
+			}
+		}()
+	}
+}
+
+func TestUnoconv_StartupMessage(t *testing.T) {
+	for i, tc := range []struct {
+		disableListener bool
+		expectMessage   string
+	}{
+		{
+			disableListener: true,
+			expectMessage:   "listener disabled",
+		},
+		{
+			expectMessage: "listener started on port 0",
+		},
+	} {
+		mod := Unoconv{
+			disableListener: tc.disableListener,
+		}
+
+		actual := mod.StartupMessage()
+		if actual != tc.expectMessage {
+			t.Errorf("test %d: expected '%s' but got '%s'", i, tc.expectMessage, actual)
+		}
+	}
+}
+
+func TestUnoconv_Stop(t *testing.T) {
+	for i, tc := range []struct {
+		start           bool
+		disableListener bool
+		timeout         time.Duration
+		expectErr       bool
+	}{
+		{
+			disableListener: true,
+		},
+		{
+			expectErr: true,
+		},
+		{
+			start:   true,
+			timeout: time.Duration(1) * time.Nanosecond,
+		},
+	} {
+		func() {
+			mod := &Unoconv{
+				binPath:         os.Getenv("UNOCONV_BIN_PATH"),
+				disableListener: tc.disableListener,
+				logger:          zap.NewNop(),
+			}
+
+			if tc.start {
+				err := mod.Start()
+				if err != nil {
+					t.Fatalf("test %d: expected no error but got: %v", i, err)
+				}
+			}
+
+			var err error
+
+			if tc.timeout == 0 {
+				err = mod.Stop(context.TODO())
+			} else {
+				ctx, cancel := context.WithTimeout(context.Background(), tc.timeout)
+				defer cancel()
+
+				err = mod.Stop(ctx)
+			}
+
+			if tc.expectErr && err == nil {
+				t.Errorf("test %d: expected error but got: %v", i, err)
+			}
+
+			if !tc.expectErr && err != nil {
+				t.Errorf("test %d: expected no error but got: %v", i, err)
+			}
+		}()
+	}
+}
+
+func TestUnoconv_Metrics(t *testing.T) {
 	metrics, err := new(Unoconv).Metrics()
 	if err != nil {
-		t.Errorf("expected no error but got: %v", err)
+		t.Fatalf("expected no error but got: %v", err)
 	}
 
-	if len(metrics) != 1 {
-		t.Errorf("expected %d metrics, but got %d", 1, len(metrics))
+	if len(metrics) != 3 {
+		t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
 	}
 
 	actual := metrics[0].Read()
 	if actual != 0 {
 		t.Errorf("expected %d unoconv instances, but got %f", 0, actual)
 	}
+
+	actual = metrics[1].Read()
+	if actual != 0 {
+		t.Errorf("expected %d unoconv listener instances, but got %f", 0, actual)
+	}
+
+	actual = metrics[2].Read()
+	if actual != 0 {
+		t.Errorf("expected %d processes in the queue, but got %f", 0, actual)
+	}
 }
 
 func TestUnoconv_Unoconv(t *testing.T) {
@@ -89,17 +305,26 @@ func TestUnoconv_Unoconv(t *testing.T) {
 func TestUnoconv_PDF(t *testing.T) {
 	for i, tc := range []struct {
 		ctx       context.Context
+		mod       Unoconv
 		logger    *zap.Logger
 		inputPath string
 		options   Options
 		expectErr bool
 	}{
 		{
+			mod: Unoconv{
+				binPath:         os.Getenv("UNOCONV_BIN_PATH"),
+				disableListener: true,
+			},
 			logger:    zap.NewNop(),
 			expectErr: true,
 		},
 		{
-			ctx:       context.Background(),
+			ctx: context.Background(),
+			mod: Unoconv{
+				binPath:         os.Getenv("UNOCONV_BIN_PATH"),
+				disableListener: true,
+			},
 			logger:    zap.NewExample(),
 			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
 			options: Options{
@@ -109,7 +334,11 @@ func TestUnoconv_PDF(t *testing.T) {
 			},
 		},
 		{
-			ctx:       context.Background(),
+			ctx: context.Background(),
+			mod: Unoconv{
+				binPath:         os.Getenv("UNOCONV_BIN_PATH"),
+				disableListener: true,
+			},
 			logger:    zap.NewNop(),
 			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
 			options: Options{
@@ -124,19 +353,40 @@ func TestUnoconv_PDF(t *testing.T) {
 
 				return ctx
 			}(),
+			mod: Unoconv{
+				binPath:         os.Getenv("UNOCONV_BIN_PATH"),
+				disableListener: true,
+			},
+			logger:    zap.NewNop(),
+			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
+			expectErr: true,
+		},
+		{
+			ctx: context.Background(),
+			mod: Unoconv{
+				binPath: os.Getenv("UNOCONV_BIN_PATH"),
+				logger:  zap.NewNop(),
+			},
+			logger:    zap.NewNop(),
+			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
+		},
+		{
+			ctx: func() context.Context {
+				ctx, cancel := context.WithCancel(context.TODO())
+				defer cancel()
+
+				return ctx
+			}(),
+			mod: Unoconv{
+				binPath: os.Getenv("UNOCONV_BIN_PATH"),
+				logger:  zap.NewNop(),
+			},
 			logger:    zap.NewNop(),
 			inputPath: "/tests/test/testdata/libreoffice/sample1.docx",
 			expectErr: true,
 		},
 	} {
 		func() {
-			mod := new(Unoconv)
-
-			err := mod.Provision(nil)
-			if err != nil {
-				t.Fatalf("test %d: expected error but got: %v", i, err)
-			}
-
 			outputDir, err := gotenberg.MkdirAll()
 			if err != nil {
 				t.Fatalf("test %d: expected error but got: %v", i, err)
@@ -149,7 +399,17 @@ func TestUnoconv_PDF(t *testing.T) {
 				}
 			}()
 
-			err = mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options)
+			if !tc.mod.disableListener {
+				err = tc.mod.Start()
+				if err != nil {
+					t.Fatalf("test %d: expected no error but got: %v", i, err)
+				}
+
+				// Let's give it some room to start.
+				time.Sleep(time.Duration(1) * time.Second)
+			}
+
+			err = tc.mod.PDF(tc.ctx, tc.logger, tc.inputPath, outputDir+"/foo.pdf", tc.options)
 
 			if tc.expectErr && err == nil {
 				t.Errorf("test %d: expected error but got: %v", i, err)
@@ -158,6 +418,16 @@ func TestUnoconv_PDF(t *testing.T) {
 			if !tc.expectErr && err != nil {
 				t.Errorf("test %d: expected no error but got: %v", i, err)
 			}
+
+			if !tc.mod.disableListener {
+				ctx, cancel := context.WithTimeout(context.Background(), time.Duration(1)*time.Nanosecond)
+				defer cancel()
+
+				err = tc.mod.Stop(ctx)
+				if err != nil {
+					t.Fatalf("test %d: expected no error but got: %v", i, err)
+				}
+			}
 		}()
 	}
 }
@@ -173,3 +443,11 @@ func TestUnoconv_Extensions(t *testing.T) {
 		t.Errorf("expected %d extensions but got %d", expect, actual)
 	}
 }
+
+// Interface guards.
+var (
+	_ gotenberg.Module         = (*ProtoModule)(nil)
+	_ gotenberg.Validator      = (*ProtoValidator)(nil)
+	_ gotenberg.LoggerProvider = (*ProtoLoggerProvider)(nil)
+	_ gotenberg.Module         = (*ProtoLoggerProvider)(nil)
+)
diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go
index 502a663..f3cf827 100644
--- a/pkg/modules/pdftk/pdftk_test.go
+++ b/pkg/modules/pdftk/pdftk_test.go
@@ -65,11 +65,11 @@ func TestPDFtk_Validate(t *testing.T) {
 func TestPDFtk_Metrics(t *testing.T) {
 	metrics, err := new(PDFtk).Metrics()
 	if err != nil {
-		t.Errorf("expected no error but got: %v", err)
+		t.Fatalf("expected no error but got: %v", err)
 	}
 
 	if len(metrics) != 1 {
-		t.Errorf("expected %d metrics, but got %d", 1, len(metrics))
+		t.Fatalf("expected %d metrics, but got %d", 1, len(metrics))
 	}
 
 	actual := metrics[0].Read()
diff --git a/pkg/modules/prometheus/prometheus_test.go b/pkg/modules/prometheus/prometheus_test.go
index 2fbb11a..383e12f 100644
--- a/pkg/modules/prometheus/prometheus_test.go
+++ b/pkg/modules/prometheus/prometheus_test.go
@@ -296,8 +296,8 @@ func TestPrometheus_StartupMessage(t *testing.T) {
 		expectMessage  string
 	}{
 		{
-			expectMessage:  "application not started (collect disabled by user)",
 			disableCollect: true,
+			expectMessage:  "collect disabled",
 		},
 		{
 			expectMessage: "collecting metrics",