Skip to content

Commit 5f071ef

Browse files
committed
perf: reuse one Docutils interpreter instead of one per file
Signed-off-by: Joseph Kato <joseph@jdkato.io>
1 parent 4fd0ba6 commit 5f071ef

3 files changed

Lines changed: 309 additions & 4 deletions

File tree

internal/lint/lint.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ type Linter struct {
4141
mdx *procPool
4242
mdxOnce sync.Once
4343

44+
// rst is the same arrangement for Docutils.
45+
rst *procPool
46+
rstOnce sync.Once
47+
4448
// inScope lists the rules whose scope matches a given block scope, keyed by
4549
// the block's scope and parent.
4650
//
@@ -465,4 +469,9 @@ func (l *Linter) stopExternal() {
465469
l.mdx = nil
466470
l.mdxOnce = sync.Once{}
467471
}
472+
if l.rst != nil {
473+
l.rst.stop()
474+
l.rst = nil
475+
l.rstOnce = sync.Once{}
476+
}
468477
}

internal/lint/rst.go

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
package lint
22

33
import (
4+
"bufio"
45
"errors"
6+
"os"
7+
"path/filepath"
58
"regexp"
69
"strings"
10+
"sync"
711

812
"github.com/errata-ai/vale/v3/internal/core"
913
"github.com/errata-ai/vale/v3/internal/system"
@@ -30,6 +34,125 @@ var rstArgs = []string{
3034
"--no-section-numbering",
3135
}
3236

37+
// Converting a document with Docutils takes a few milliseconds; starting
38+
// Python and importing Docutils takes seventy. Vale paid the latter once per
39+
// file, so on a corpus of reStructuredText nearly all of the time went to
40+
// starting the same interpreter over and over.
41+
//
42+
// rstServer keeps one interpreter up and converts documents as they arrive. It
43+
// parses the flags through Docutils' own command-line handling rather than
44+
// mapping them to settings by hand, so the pooled settings cannot drift from
45+
// what a per-file rst2html invocation would use.
46+
const rstServer = `import sys
47+
from docutils.core import Publisher, publish_string
48+
49+
_pub = Publisher()
50+
_pub.set_components("standalone", "restructuredtext", "html4css1")
51+
_pub.process_command_line(argv=sys.argv[1:])
52+
SETTINGS = _pub.settings
53+
54+
buf = sys.stdin.buffer
55+
out = sys.stdout.buffer
56+
57+
while True:
58+
header = buf.readline()
59+
if not header:
60+
break
61+
n = int(header)
62+
if n < 0:
63+
break
64+
doc = buf.read(n) if n else b""
65+
try:
66+
b = publish_string(
67+
source=doc.decode("utf-8"),
68+
source_path="<stdin>",
69+
writer_name="html4css1",
70+
settings=SETTINGS,
71+
)
72+
out.write(b"ok %d\n" % len(b))
73+
out.write(b)
74+
except Exception as e:
75+
m = str(e).encode("utf-8", "replace")
76+
out.write(b"err %d\n" % len(m))
77+
out.write(m)
78+
out.flush()`
79+
80+
var (
81+
rstOnce sync.Once
82+
rstDirect []string // <python> -c <server>, or nil
83+
)
84+
85+
// rstInterpreter finds the Python that can import Docutils.
86+
//
87+
// It is not necessarily the `python3` on PATH: rst2html is installed with a
88+
// shebang naming the interpreter of the environment Docutils was installed
89+
// into, and on a machine with several Pythons the one on PATH often cannot
90+
// import it. So the script is asked which interpreter it runs on.
91+
func rstInterpreter(exe string) string {
92+
resolved, err := filepath.EvalSymlinks(exe)
93+
if err != nil {
94+
return ""
95+
}
96+
97+
file, err := os.Open(resolved)
98+
if err != nil {
99+
return ""
100+
}
101+
defer file.Close()
102+
103+
line, err := bufio.NewReader(file).ReadString('\n')
104+
if err != nil || !strings.HasPrefix(line, "#!") {
105+
return ""
106+
}
107+
108+
fields := strings.Fields(strings.TrimPrefix(strings.TrimSpace(line), "#!"))
109+
if len(fields) == 0 {
110+
return ""
111+
}
112+
113+
// `#!/usr/bin/env python3` names the interpreter in the second field.
114+
if filepath.Base(fields[0]) == "env" {
115+
if len(fields) < 2 {
116+
return ""
117+
}
118+
return system.Which([]string{fields[1]})
119+
}
120+
121+
return fields[0]
122+
}
123+
124+
// rstFastPath returns the argv prefix for converting through a long-lived
125+
// interpreter, or nil when that could not be established.
126+
func rstFastPath(exe string) []string {
127+
rstOnce.Do(func() {
128+
python := rstInterpreter(exe)
129+
if python == "" {
130+
return
131+
}
132+
133+
candidate := []string{python, "-c", rstServer}
134+
135+
// Trust it only after a document has made the round trip. The probe
136+
// carries a non-ASCII character on purpose: a mismatched default
137+
// encoding is what broke the first version of the AsciiDoc pool, and
138+
// an ASCII-only probe would have passed anyway.
139+
probe, err := startExtProc(candidate, rstArgs)
140+
if err != nil {
141+
return
142+
}
143+
defer probe.close()
144+
145+
got, err := probe.convert("naïve body\n")
146+
if err != nil || !strings.Contains(got, "naïve body") {
147+
return
148+
}
149+
150+
rstDirect = candidate
151+
})
152+
153+
return rstDirect
154+
}
155+
33156
func (l *Linter) lintRST(f *core.File) error {
34157
var html string
35158

@@ -53,19 +176,44 @@ func (l *Linter) lintRST(f *core.File) error {
53176
s = reSphinx.ReplaceAllString(s, ".. code::")
54177
s = reCodeBlock.ReplaceAllString(s, "::")
55178

56-
html, err = callRst(s, rst2html)
179+
html, err = l.callRst(s, rst2html)
57180
if err != nil {
58181
return core.NewE100(f.Path, err)
59182
}
60183

61184
return l.lintHTMLTokens(f, []byte(html), 0)
62185
}
63186

64-
func callRst(text, lib string) (string, error) {
65-
html, err := system.ExecuteWithInput(lib, text, rstArgs...)
187+
// callRst converts one document, over a pooled interpreter when Docutils can
188+
// be reached directly.
189+
func (l *Linter) callRst(text, exe string) (string, error) {
190+
if direct := rstFastPath(exe); direct != nil {
191+
l.rstOnce.Do(func() {
192+
pool, err := newProcPool(direct, rstArgs, adocConcurrency)
193+
if err == nil {
194+
l.rst = pool
195+
}
196+
})
197+
198+
if l.rst != nil {
199+
html, err := l.rst.convert(text, direct, rstArgs)
200+
if err != nil {
201+
return "", err
202+
}
203+
return rstBody(html), nil
204+
}
205+
}
206+
207+
html, err := system.ExecuteWithInput(exe, text, rstArgs...)
66208
if err != nil {
67209
return "", err
68210
}
211+
212+
return rstBody(html), nil
213+
}
214+
215+
// rstBody takes the document body out of a full rst2html page.
216+
func rstBody(html string) string {
69217
html = strings.ReplaceAll(html, "\r", "")
70218

71219
bodyStart := strings.Index(html, "<body>\n")
@@ -80,5 +228,5 @@ func callRst(text, lib string) (string, error) {
80228
}
81229
}
82230

83-
return html[bodyStart+7 : bodyEnd], nil
231+
return html[bodyStart+7 : bodyEnd]
84232
}

internal/lint/rst_pool_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package lint
2+
3+
import (
4+
"os/exec"
5+
"runtime"
6+
"strings"
7+
"testing"
8+
9+
"github.com/errata-ai/vale/v3/internal/system"
10+
)
11+
12+
const rstFixture = "../../testdata/fixtures/formats/test.rst"
13+
14+
func rstExecutable() string {
15+
return system.Which([]string{
16+
"rst2html", "rst2html.py", "rst2html-3", "rst2html-3.py"})
17+
}
18+
19+
// The pool reuses one interpreter for every document, so its Docutils settings
20+
// are established once instead of per file. That is the whole risk of the
21+
// change: if the pooled settings differ at all from what a per-file rst2html
22+
// invocation uses, Vale silently lints different HTML.
23+
func TestRSTPoolMatchesSpawnedOutput(t *testing.T) {
24+
exe := rstExecutable()
25+
if exe == "" {
26+
t.Skip("rst2html not installed")
27+
}
28+
29+
argv := rstFastPath(exe)
30+
if argv == nil {
31+
t.Skip("docutils not reachable from the rst2html interpreter")
32+
}
33+
34+
pool, err := newProcPool(argv, rstArgs, 1)
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
defer pool.stop()
39+
40+
// Non-ASCII and a title on purpose: a mismatched default encoding and the
41+
// source name reaching <title> are both ways the two paths can diverge
42+
// without any visible error.
43+
for _, doc := range []string{
44+
"Title\n=====\n\nnaïve body — with punctuation.\n",
45+
"no title here, just a paragraph\n",
46+
"- a list\n- of items\n\n``literal`` and *emphasis*\n",
47+
// Section numbering, table-of-contents backlinks and footnote
48+
// backlinks are each turned off by a flag in rstArgs, and each shows
49+
// up inside <body>. Without a document like this the comparison
50+
// passes even if the pooled interpreter ignores the flags entirely.
51+
".. sectnum::\n\n.. contents::\n\nFirst\n=====\n\n" +
52+
"Body with a footnote [1]_.\n\nSecond\n======\n\nMore.\n\n.. [1] The note.\n",
53+
} {
54+
pooled, cErr := pool.convert(doc, argv, rstArgs)
55+
if cErr != nil {
56+
t.Fatalf("pooled convert failed: %v", cErr)
57+
}
58+
59+
spawned, sErr := system.ExecuteWithInput(exe, doc, rstArgs...)
60+
if sErr != nil {
61+
t.Fatalf("spawned convert failed: %v", sErr)
62+
}
63+
64+
if got, want := rstBody(pooled), rstBody(spawned); got != want {
65+
t.Errorf("pooled and spawned output differ for %q:\n pooled: %q\nspawned: %q",
66+
doc, got, want)
67+
}
68+
}
69+
}
70+
71+
// A pool with no owner is a pool nothing stops. The CLI gets away with it --
72+
// exiting reaps the children -- but the language server lints many times in one
73+
// process, and every run would leave its interpreters behind.
74+
func TestRSTPoolDoesNotLeakProcesses(t *testing.T) {
75+
if exe := rstExecutable(); exe == "" || rstFastPath(exe) == nil {
76+
t.Skip("docutils not reachable on this machine")
77+
}
78+
79+
before := pythonProcessCount(t)
80+
81+
linter, err := initLinter()
82+
if err != nil {
83+
t.Fatal(err)
84+
}
85+
86+
// A real reStructuredText file, so the run actually starts the pool -- a
87+
// path that lints nothing would pass this test without testing anything.
88+
linted, err := linter.Lint([]string{rstFixture}, "*")
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
if len(linted) == 0 {
93+
t.Fatal("nothing linted; the pool was never started")
94+
}
95+
if linter.rst != nil {
96+
t.Error("pool outlived the run")
97+
}
98+
99+
// Give the OS a moment to reap.
100+
runtime.Gosched()
101+
if after := pythonProcessCount(t); after > before {
102+
t.Errorf("leaked processes: %d before, %d after", before, after)
103+
}
104+
}
105+
106+
// The pool has to survive a document Docutils rejects: later files still
107+
// convert, rather than the run losing its warm interpreters.
108+
func TestRSTPoolSurvivesABadDocument(t *testing.T) {
109+
exe := rstExecutable()
110+
if exe == "" {
111+
t.Skip("rst2html not installed")
112+
}
113+
114+
argv := rstFastPath(exe)
115+
if argv == nil {
116+
t.Skip("docutils not reachable from the rst2html interpreter")
117+
}
118+
119+
pool, err := newProcPool(argv, rstArgs, 1)
120+
if err != nil {
121+
t.Fatal(err)
122+
}
123+
defer pool.stop()
124+
125+
// A malformed directive, which Docutils reports at the severity that
126+
// --halt=5 stops on.
127+
if _, err = pool.convert(".. |bad\xff| replace::\n", argv, rstArgs); err == nil {
128+
t.Log("bad document was accepted; the recovery path is untested here")
129+
}
130+
131+
got, err := pool.convert("still working\n", argv, rstArgs)
132+
if err != nil {
133+
t.Fatalf("pool did not recover: %v", err)
134+
}
135+
if !strings.Contains(got, "still working") {
136+
t.Errorf("unexpected output after recovery: %q", got)
137+
}
138+
}
139+
140+
func pythonProcessCount(t *testing.T) int {
141+
t.Helper()
142+
143+
out, err := exec.Command("ps", "-eo", "comm").Output()
144+
if err != nil {
145+
t.Skip("ps unavailable")
146+
}
147+
return strings.Count(string(out), "python")
148+
}

0 commit comments

Comments
 (0)