Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PERF-01 to PERF-08 on release builds, written as a record."""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import samples
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
APPS = {"ely-doc": "ElyDoc", "ely-table": "ElyTable", "ely-show": "ElyShow",
|
||||
"ely-pdf": "ElyPDF", "ely-tree": "ElyTree", "ely-mark": "ElyMark"}
|
||||
TARGETS = {"PERF-01": 3000, "PERF-02": 100, "PERF-03": 5000, "PERF-04": 5000,
|
||||
"PERF-05": 3000, "PERF-06": 1000, "PERF-07": 2000}
|
||||
|
||||
|
||||
def sh(*args, **kw):
|
||||
return subprocess.run(args, check=True, capture_output=True, text=True, **kw).stdout
|
||||
|
||||
|
||||
def tools(folder):
|
||||
for tool in ["ax-values", "ax-press", "click"]:
|
||||
sh("swiftc", "-O", "-o", os.path.join(folder, tool), os.path.join(ROOT, "tests", "scripts", tool + ".swift"))
|
||||
|
||||
|
||||
class App:
|
||||
"""A release app run, timed from spawn, read via log."""
|
||||
|
||||
def __init__(self, slug, home, args=(), smoke="hold"):
|
||||
self.slug, self.home, self.tools = slug, home, os.path.dirname(home)
|
||||
self.log = os.path.join(home, "Library", "Application Support", "ElyOffice", slug, "ely.log")
|
||||
# Starts may trim the log; each run starts fresh.
|
||||
if os.path.exists(self.log):
|
||||
os.remove(self.log)
|
||||
self.seen = 0
|
||||
env = dict(os.environ, HOME=home, ELY_SMOKE=smoke, ELY_LOCALE="en", ELY_NO_DIALOG="1",
|
||||
ELY_LOG="debug", ELY_STALLS="1")
|
||||
self.start = time.monotonic()
|
||||
self.process = subprocess.Popen([os.path.join(ROOT, "target", "release", slug), *args], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
||||
|
||||
def lines(self):
|
||||
if not os.path.exists(self.log):
|
||||
return []
|
||||
with open(self.log, errors="replace") as log:
|
||||
log.seek(self.seen)
|
||||
return log.read().splitlines()
|
||||
|
||||
def wait(self, pattern, after=None, timeout=60):
|
||||
"""Milliseconds from spawn until a line matches, past `after`."""
|
||||
called = time.monotonic()
|
||||
while time.monotonic() - called < timeout:
|
||||
lines = self.lines()
|
||||
if after is not None:
|
||||
at = next((i for i, line in enumerate(lines) if re.search(after, line)), None)
|
||||
lines = [] if at is None else lines[at + 1:]
|
||||
if any(re.search(pattern, line) for line in lines):
|
||||
return (time.monotonic() - self.start) * 1000
|
||||
time.sleep(0.01)
|
||||
tail = "\n".join(self.lines()[-12:])
|
||||
raise RuntimeError(f"{self.slug}: no {pattern!r} in {timeout} s; the log ends:\n{tail}")
|
||||
|
||||
def ready(self, timeout=60):
|
||||
"""ELY_SMOKE=1: milliseconds until the first frame."""
|
||||
out, _ = self.process.communicate(timeout=timeout)
|
||||
took = (time.monotonic() - self.start) * 1000
|
||||
if f"ready {APPS[self.slug]}" not in out:
|
||||
raise RuntimeError(f"{self.slug} did not report ready: {out!r}")
|
||||
# Its engine may still be starting; the next run waits.
|
||||
for _ in range(300):
|
||||
if subprocess.run(["pgrep", "-f", self.home], capture_output=True).returncode != 0:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
return took
|
||||
|
||||
def family(self):
|
||||
out = sh("ps", "-A", "-o", "pid=,ppid=,rss=")
|
||||
rows = [tuple(map(int, line.split())) for line in out.splitlines()]
|
||||
ours, grew = {self.process.pid}, True
|
||||
while grew:
|
||||
grew = False
|
||||
for pid, ppid, _ in rows:
|
||||
if ppid in ours and pid not in ours:
|
||||
ours.add(pid)
|
||||
grew = True
|
||||
return ours, sum(rss for pid, _, rss in rows if pid in ours)
|
||||
|
||||
def memory_mb(self):
|
||||
return self.family()[1] / 1024
|
||||
|
||||
def files(self):
|
||||
ours, _ = self.family()
|
||||
return sum(len(sh("lsof", "-p", str(pid)).splitlines()) for pid in ours)
|
||||
|
||||
def front(self):
|
||||
sh("osascript", "-e", f'tell application "System Events" to set frontmost of (first process whose unix id is {self.process.pid}) to true')
|
||||
time.sleep(0.5)
|
||||
|
||||
def ax(self):
|
||||
return sh(os.path.join(self.tools, "ax-values"), str(self.process.pid))
|
||||
|
||||
def stop(self):
|
||||
ours, _ = self.family()
|
||||
for pid in ours:
|
||||
try:
|
||||
os.kill(pid, 9)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self.process.wait()
|
||||
|
||||
|
||||
def keys(script):
|
||||
sh("osascript", "-e", 'tell application "System Events"', "-e", script, "-e", "end tell")
|
||||
|
||||
|
||||
def percentile(values, p):
|
||||
ordered = sorted(values)
|
||||
return ordered[min(len(ordered) - 1, int(len(ordered) * p))]
|
||||
|
||||
|
||||
# A page that failed to paint voids the run.
|
||||
PAINT_FAILED = r"lok: tile \(.*\) failed|pdf: drawing a page failed"
|
||||
|
||||
|
||||
def opened(slug, home, sample, marks, runs):
|
||||
"""Spawn with a file until `marks` all appear, in order."""
|
||||
times, memory = [], []
|
||||
for _ in range(runs):
|
||||
app = App(slug, home, [sample])
|
||||
try:
|
||||
after = None
|
||||
for mark in marks:
|
||||
took = app.wait(mark, after)
|
||||
after = mark
|
||||
failed = [line for line in app.lines() if re.search(PAINT_FAILED, line)]
|
||||
if failed:
|
||||
raise RuntimeError(f"{slug}: {sample} did not paint: {failed[0]}")
|
||||
times.append(took)
|
||||
memory.append(app.memory_mb())
|
||||
finally:
|
||||
app.stop()
|
||||
return times, memory
|
||||
|
||||
|
||||
def laid_out(work, sample, pages):
|
||||
"""The kernel lays `sample` out to exactly `pages` pages."""
|
||||
app = App("ely-doc", os.path.join(work, "home-pages"), [sample])
|
||||
try:
|
||||
app.wait(rf"lok: {pages} pages$", timeout=120)
|
||||
time.sleep(3)
|
||||
counts = [line for line in app.lines() if re.search(r"lok: \d+ pages$", line)]
|
||||
if not counts[-1].endswith(f" {pages} pages"):
|
||||
raise RuntimeError(f"{sample} lays out as {counts[-1]}, not {pages} pages")
|
||||
finally:
|
||||
app.stop()
|
||||
|
||||
|
||||
def perf01(work, runs):
|
||||
rows = []
|
||||
for slug in APPS:
|
||||
home = os.path.join(work, "home-" + slug)
|
||||
first = App(slug, home, smoke="1").ready()
|
||||
daily = [App(slug, home, smoke="1").ready() for _ in range(runs)]
|
||||
rows.append((slug, first, daily))
|
||||
return rows
|
||||
|
||||
|
||||
def perf02(work, runs):
|
||||
"""Key to new tile in ElyDoc, and main-thread stalls meanwhile."""
|
||||
app = App("ely-doc", os.path.join(work, "home-typing"))
|
||||
try:
|
||||
app.wait("opened Untitled")
|
||||
app.front()
|
||||
tick = r"lok: edit to tile (\d+) ms"
|
||||
for n in range(runs):
|
||||
keys('keystroke "x"')
|
||||
# Each key must show; a lost one fails the run.
|
||||
for _ in range(500):
|
||||
if sum(1 for line in app.lines() if re.search(tick, line)) > n:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
else:
|
||||
raise RuntimeError(f"ely-doc: key {n + 1} of {runs} never reached a tile")
|
||||
time.sleep(0.15)
|
||||
lines = app.lines()
|
||||
finally:
|
||||
app.stop()
|
||||
ticks = [int(m.group(1)) for line in lines if (m := re.search(tick, line))]
|
||||
if len(ticks) != runs:
|
||||
raise RuntimeError(f"ely-doc: {runs} keys gave {len(ticks)} timings")
|
||||
stalls = [int(m.group(1)) for line in lines if (m := re.search(r"main thread was busy (\d+) ms", line))]
|
||||
return ticks, stalls
|
||||
|
||||
|
||||
def perf06(work, repo, runs):
|
||||
"""History first screen, then a common query's first results."""
|
||||
home = os.path.join(work, "home-ely-tree")
|
||||
first, found = [], []
|
||||
for _ in range(runs):
|
||||
app = App("ely-tree", home, [repo])
|
||||
try:
|
||||
app.wait("tree: opened")
|
||||
app.front()
|
||||
pressed = (time.monotonic() - app.start) * 1000
|
||||
sh(os.path.join(work, "ax-press"), str(app.process.pid), "History")
|
||||
first.append(app.wait(r"history shows \d+ commits, searched false") - pressed)
|
||||
field = next(line for line in app.ax().splitlines() if line.startswith("AXTextField"))
|
||||
x, y = field.rsplit("at=", 1)[1].split(",")
|
||||
sh(os.path.join(work, "click"), x, y)
|
||||
keys('keystroke "part 42"')
|
||||
asked = (time.monotonic() - app.start) * 1000
|
||||
keys("key code 36")
|
||||
found.append(app.wait(r"searched true") - asked)
|
||||
finally:
|
||||
app.stop()
|
||||
return first, found
|
||||
|
||||
|
||||
def raise_main(app):
|
||||
"""The document window as key, once a dialog closed."""
|
||||
sh("osascript", "-e", f'tell application "System Events" to tell (first process whose unix id is {app.process.pid}) '
|
||||
f'to perform action "AXRaise" of window "{APPS[app.slug]}"')
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
def perf08(work, cycles=40):
|
||||
"""New and close a document; memory, handles and live documents."""
|
||||
app = App("ely-doc", os.path.join(work, "home-cycles"))
|
||||
docs = r"lok-worker: (\d+) documents open"
|
||||
|
||||
def open_docs():
|
||||
counts = [int(m.group(1)) for line in app.lines() if (m := re.search(docs, line))]
|
||||
return counts[-1] if counts else None
|
||||
|
||||
try:
|
||||
app.wait("opened Untitled")
|
||||
app.front()
|
||||
time.sleep(2)
|
||||
baseline = (app.memory_mb(), app.files(), open_docs())
|
||||
seen = []
|
||||
for n in range(cycles):
|
||||
keys('keystroke "n" using command down')
|
||||
for _ in range(40):
|
||||
if "title=Use template" in app.ax():
|
||||
break
|
||||
time.sleep(0.25)
|
||||
sh(os.path.join(work, "ax-press"), str(app.process.pid), "Use template", "AXButton")
|
||||
app.wait(f"opened Untitled {n + 2} ", timeout=30)
|
||||
time.sleep(1)
|
||||
# A keystroke can miss a window still taking focus.
|
||||
for _ in range(3):
|
||||
app.front()
|
||||
raise_main(app)
|
||||
keys('keystroke "w" using command down')
|
||||
try:
|
||||
app.wait(f"office: closed Untitled {n + 2}$", timeout=5)
|
||||
break
|
||||
except RuntimeError:
|
||||
# A keystroke that lost its Command edits the document.
|
||||
if "title=Don't Save" in app.ax():
|
||||
raise RuntimeError(f"ely-doc: Untitled {n + 2} was edited by a stray key; it asks to save")
|
||||
continue
|
||||
else:
|
||||
said = [line for line in app.lines() if re.search("clos|dialog|opened|focus", line)]
|
||||
raise RuntimeError(f"ely-doc: Untitled {n + 2} never closed: {said[-8:]}")
|
||||
# The kernel lets go in the background.
|
||||
for _ in range(100):
|
||||
if open_docs() == baseline[2]:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.5)
|
||||
seen.append((app.memory_mb(), app.files(), open_docs()))
|
||||
finally:
|
||||
app.stop()
|
||||
# The first dialog loads system fonts and AppKit tables once.
|
||||
first, last = seen[0], seen[-1]
|
||||
if last[1] != first[1] or last[2] != baseline[2]:
|
||||
raise RuntimeError(f"ely-doc: after {cycles} cycles {last[1:]} files and documents, "
|
||||
f"not {first[1]} and {baseline[2]}")
|
||||
return baseline, seen
|
||||
|
||||
def environment(found):
|
||||
gpu = re.findall(r"Chipset Model: (.+)", sh("system_profiler", "SPDisplaysDataType"))
|
||||
fonts = sum(len(files) for folder in ["/System/Library/Fonts", "/Library/Fonts"] for _, _, files in os.walk(folder))
|
||||
builds = {slug: hashlib.sha256(open(os.path.join(ROOT, "target", "release", slug), "rb").read()).hexdigest()[:16]
|
||||
for slug in APPS}
|
||||
return {
|
||||
"date": datetime.date.today().isoformat(),
|
||||
"machine": sh("sysctl", "-n", "hw.model").strip(),
|
||||
"cpu": sh("sysctl", "-n", "machdep.cpu.brand_string").strip(),
|
||||
"cores": sh("sysctl", "-n", "hw.ncpu").strip(),
|
||||
"memory_gb": int(sh("sysctl", "-n", "hw.memsize")) // 2**30,
|
||||
"gpu": ", ".join(gpu),
|
||||
"system": f"macOS {platform.mac_ver()[0]}",
|
||||
"fonts": fonts,
|
||||
"builds": builds,
|
||||
"samples": {name: value[1] for name, value in found.items()},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--runs", type=int, default=30)
|
||||
parser.add_argument("--only", nargs="*")
|
||||
parser.add_argument("--out", default=os.path.join(ROOT, "docs", "perf"))
|
||||
args = parser.parse_args()
|
||||
want = lambda perf: not args.only or perf in args.only
|
||||
if sys.platform != "darwin":
|
||||
sys.exit("SKIP: the bench drives macOS windows")
|
||||
sh("cargo", "build", "--release", "-q", "--bins", "-p", "ely-lok", "-p", "ely-pdf-engine",
|
||||
*[arg for slug in APPS for arg in ("-p", slug)], cwd=ROOT)
|
||||
git = os.path.join(ROOT, "engines", "git", "bin", "git")
|
||||
found = samples.made(os.path.join(ROOT, "engines", "bench"), git)
|
||||
work = tempfile.mkdtemp()
|
||||
tools(work)
|
||||
rows = []
|
||||
try:
|
||||
if want("PERF-01"):
|
||||
for slug, first, daily in perf01(work, args.runs):
|
||||
rows.append(("PERF-01", f"{slug} first run", [first], None))
|
||||
rows.append(("PERF-01", f"{slug} daily start", daily, None))
|
||||
# The window opens first; the blank document follows the engine.
|
||||
for slug in ["ely-doc", "ely-table", "ely-show"]:
|
||||
blank = []
|
||||
for _ in range(args.runs):
|
||||
app = App(slug, os.path.join(work, "home-" + slug))
|
||||
try:
|
||||
blank.append(app.wait("opened Untitled"))
|
||||
finally:
|
||||
app.stop()
|
||||
rows.append(("PERF-01", f"{slug} blank document ready", blank, None))
|
||||
if want("PERF-02"):
|
||||
ticks, stalls = perf02(work, args.runs)
|
||||
rows.append(("PERF-02", "ElyDoc key to new tile", ticks, None))
|
||||
rows.append(("PERF-02", f"main thread busy >100 ms: {len(stalls)} times", stalls or [0], None))
|
||||
cases = [("PERF-03", "ely-doc", "doc.docx", ["opened doc.docx", r"lok: tile \(\d+, \d+\) shown"]),
|
||||
("PERF-04", "ely-table", "sheet.xlsx", ["opened sheet.xlsx", r"lok: tile \(\d+, \d+\) shown"]),
|
||||
("PERF-05", "ely-pdf", "pages.pdf", ["opened pages.pdf", r"pdf: a tile of page 0 drawn"]),
|
||||
("PERF-07", "ely-mark", "notes.md", ["opened notes.md", r"mark: shown as \"visual\""])]
|
||||
if want("PERF-03"):
|
||||
laid_out(work, found["doc.docx"][0], 100)
|
||||
for perf, slug, name, marks in cases:
|
||||
if want(perf):
|
||||
times, memory = opened(slug, os.path.join(work, "home-" + slug), found[name][0], marks, args.runs)
|
||||
rows.append((perf, f"{APPS[slug]} {name}", times, max(memory)))
|
||||
if want("PERF-06"):
|
||||
first, searched = perf06(work, found["repo"][0], args.runs)
|
||||
rows.append(("PERF-06", "ElyTree history, press to first screen", first, None))
|
||||
rows.append(("PERF-06", "ElyTree query 'part 42', first results", searched, None))
|
||||
held = perf08(work) if want("PERF-08") else None
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
write(args.out, environment(found), rows, held, args.runs)
|
||||
|
||||
|
||||
def write(out, env, rows, held, runs):
|
||||
os.makedirs(out, exist_ok=True)
|
||||
path = os.path.join(out, f"{env['date']}-{env['machine']}.md")
|
||||
lines = [f"# Benchmark {env['date']}", "", "Format: docs/perf/README.md.", "", "```json",
|
||||
json.dumps(env, indent=2), "```", "",
|
||||
"| PERF | Case | Runs | P50 ms | P95 ms | Target ms | Total memory MB |", "| --- | --- | --- | --- | --- | --- | --- |"]
|
||||
for perf, case, times, memory in rows:
|
||||
target = TARGETS.get(perf)
|
||||
p95 = percentile(times, 0.95)
|
||||
verdict = "" if target is None or "busy" in case else (" ✓" if p95 <= target else " ✗")
|
||||
mem = "" if memory is None else f"{memory:.0f}"
|
||||
lines.append(f"| {perf} | {case} | {len(times)} | {percentile(times, 0.5):.0f} | {p95:.0f}{verdict} | "
|
||||
f"{target or ''} | {mem} |")
|
||||
if held:
|
||||
(memory, files, docs), seen = held
|
||||
lines += ["", f"PERF-08, ElyDoc creating and closing a document {len(seen)} times "
|
||||
"(total memory MB / open files / kernel documents):", "",
|
||||
f"before: {memory:.0f}/{files}/{docs}", "",
|
||||
" ".join(f"{memory:.0f}/{files}/{docs}" for memory, files, docs in seen)]
|
||||
lines += ["", "PERF-09 (Agent sessions) belongs to the second release.", ""]
|
||||
with open(path, "w") as record:
|
||||
record.write("\n".join(lines))
|
||||
print(path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Benchmark samples, made the same way every time (§20.1)."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
import subprocess
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
|
||||
def noise_png(seed, width, height):
|
||||
"""An RGB PNG of seeded noise; it does not compress."""
|
||||
rng = random.Random(seed)
|
||||
rows = b"".join(b"\0" + rng.randbytes(width * 3) for _ in range(height))
|
||||
|
||||
def chunk(kind, body):
|
||||
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body))
|
||||
|
||||
head = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", head) + chunk(b"IDAT", zlib.compress(rows, 1)) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' \
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' \
|
||||
'xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" ' \
|
||||
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' \
|
||||
'xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"'
|
||||
|
||||
|
||||
def picture(n):
|
||||
cx, cy = 3600000, 2700000
|
||||
return (f'<w:p><w:r><w:drawing><wp:inline><wp:extent cx="{cx}" cy="{cy}"/><wp:docPr id="{n}" name="p{n}"/>'
|
||||
'<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">'
|
||||
f'<pic:pic><pic:nvPicPr><pic:cNvPr id="{n}" name="p{n}.png"/><pic:cNvPicPr/></pic:nvPicPr>'
|
||||
f'<pic:blipFill><a:blip r:embed="img{n}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>'
|
||||
f'<pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>'
|
||||
'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic>'
|
||||
'</wp:inline></w:drawing></w:r></w:p>')
|
||||
|
||||
|
||||
def docx(path):
|
||||
"""PERF-03: 100 pages, 20 images, under 10 MB."""
|
||||
rng = random.Random(3)
|
||||
words = "office local file engine page table slide note draft review".split()
|
||||
body = []
|
||||
for page in range(100):
|
||||
# The break runs in the heading; Heading 1 drops pageBreakBefore.
|
||||
breaks = '<w:r><w:br w:type="page"/></w:r>' if page else ""
|
||||
body.append(f'<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr>{breaks}<w:r><w:t>Page {page + 1}</w:t></w:r></w:p>')
|
||||
for _ in range(6):
|
||||
text = " ".join(rng.choice(words) for _ in range(60))
|
||||
body.append(f"<w:p><w:r><w:t>{text}</w:t></w:r></w:p>")
|
||||
if page % 5 == 0:
|
||||
body.append(picture(page // 5 + 1))
|
||||
rels = "".join(f'<Relationship Id="img{n}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" '
|
||||
f'Target="media/p{n}.png"/>' for n in range(1, 21))
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/>'
|
||||
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>')
|
||||
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>')
|
||||
archive.writestr("word/_rels/document.xml.rels", f'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{rels}</Relationships>')
|
||||
archive.writestr("word/document.xml", f'<w:document {W}><w:body>{"".join(body)}</w:body></w:document>')
|
||||
for n in range(1, 21):
|
||||
archive.writestr(f"word/media/p{n}.png", noise_png(n, 400, 300), zipfile.ZIP_STORED)
|
||||
|
||||
|
||||
def xlsx(path):
|
||||
"""PERF-04: 100,000 values and 10,000 simple formulas."""
|
||||
rows = []
|
||||
for r in range(1, 10001):
|
||||
cells = "".join(f'<c r="{chr(65 + c)}{r}"><v>{(r * 7 + c * 13) % 1000}</v></c>' for c in range(10))
|
||||
rows.append(f'<row r="{r}">{cells}<c r="K{r}"><f>SUM(A{r}:J{r})</f></c></row>')
|
||||
ns = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' \
|
||||
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("[Content_Types].xml", '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>')
|
||||
archive.writestr("_rels/.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>')
|
||||
archive.writestr("xl/_rels/workbook.xml.rels", '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="s1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>')
|
||||
archive.writestr("xl/workbook.xml", f'<workbook {ns}><sheets><sheet name="Data" sheetId="1" r:id="s1"/></sheets></workbook>')
|
||||
archive.writestr("xl/worksheets/sheet1.xml", f'<worksheet {ns}><sheetData>{"".join(rows)}</sheetData></worksheet>')
|
||||
|
||||
|
||||
def pdf(path):
|
||||
"""PERF-05: 1000 pages, about 200 MB, one image a page."""
|
||||
rng = random.Random(5)
|
||||
offsets, out = [], open(path, "wb")
|
||||
out.write(b"%PDF-1.7\n")
|
||||
|
||||
def obj(n, body):
|
||||
offsets.append((n, out.tell()))
|
||||
out.write(f"{n} 0 obj\n".encode() + body + b"\nendobj\n")
|
||||
|
||||
pages = 1000
|
||||
kids = " ".join(f"{3 + i * 3} 0 R" for i in range(pages))
|
||||
obj(1, b"<< /Type /Catalog /Pages 2 0 R >>")
|
||||
obj(2, f"<< /Type /Pages /Kids [{kids}] /Count {pages} >>".encode())
|
||||
for i in range(pages):
|
||||
page, content, image = 3 + i * 3, 4 + i * 3, 5 + i * 3
|
||||
pixels = rng.randbytes(240 * 280 * 3)
|
||||
obj(page, f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {content} 0 R "
|
||||
f"/Resources << /XObject << /I {image} 0 R >> /Font << /F <</Type /Font /Subtype /Type1 /BaseFont /Helvetica>> >> >> >>".encode())
|
||||
draw = f"q 480 0 0 560 66 160 cm /I Do Q BT /F 24 Tf 72 100 Td (Page {i + 1}) Tj ET".encode()
|
||||
obj(content, f"<< /Length {len(draw)} >>\nstream\n".encode() + draw + b"\nendstream")
|
||||
obj(image, f"<< /Type /XObject /Subtype /Image /Width 240 /Height 280 /ColorSpace /DeviceRGB "
|
||||
f"/BitsPerComponent 8 /Length {len(pixels)} >>\nstream\n".encode() + pixels + b"\nendstream")
|
||||
xref = out.tell()
|
||||
count = 3 + pages * 3
|
||||
places = dict(offsets)
|
||||
out.write(f"xref\n0 {count}\n0000000000 65535 f \n".encode())
|
||||
for n in range(1, count):
|
||||
out.write(f"{places[n]:010d} 00000 n \n".encode())
|
||||
out.write(f"trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode())
|
||||
out.close()
|
||||
|
||||
|
||||
def repo(path, git):
|
||||
"""PERF-06: a repository of 100,000 commits."""
|
||||
subprocess.run([git, "init", "-q", "-b", "main", path], check=True)
|
||||
stream = []
|
||||
for n in range(1, 100001):
|
||||
message = f"Change {n}: {'fix' if n % 3 else 'add'} part {n % 97}\n".encode()
|
||||
body = f"line {n}\n".encode()
|
||||
stream.append(b"commit refs/heads/main\n")
|
||||
stream.append(f"committer Bench <bench@ely.local> {1700000000 + n} +0000\n".encode())
|
||||
stream.append(f"data {len(message)}\n".encode() + message)
|
||||
stream.append(f"M 644 inline part{n % 97}.txt\ndata {len(body)}\n".encode() + body)
|
||||
subprocess.run([git, "-C", path, "fast-import", "--quiet"], input=b"".join(stream), check=True)
|
||||
subprocess.run([git, "-C", path, "checkout", "-q", "main"], check=True)
|
||||
|
||||
|
||||
def markdown(path):
|
||||
"""PERF-07: a 1 MB Markdown document."""
|
||||
rng = random.Random(7)
|
||||
words = "local file engine page table slide note draft review commit".split()
|
||||
parts, size, n = [], 0, 0
|
||||
while size < 1024 * 1024:
|
||||
n += 1
|
||||
block = f"## Section {n}\n\n" + " ".join(rng.choice(words) for _ in range(120)) + \
|
||||
f"\n\n- item {n}\n- **bold** and `code`\n\n```rust\nfn f{n}() -> u32 {{ {n} }}\n```\n\n"
|
||||
parts.append(block)
|
||||
size += len(block)
|
||||
with open(path, "w") as out:
|
||||
out.write("".join(parts))
|
||||
|
||||
|
||||
def made(folder, git):
|
||||
"""Each sample, made once, with its SHA-256."""
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
makers = {"doc.docx": docx, "sheet.xlsx": xlsx, "pages.pdf": pdf, "notes.md": markdown}
|
||||
found = {}
|
||||
for name, make in makers.items():
|
||||
path = os.path.join(folder, name)
|
||||
if not os.path.exists(path):
|
||||
make(path)
|
||||
found[name] = (path, hashlib.sha256(open(path, "rb").read()).hexdigest())
|
||||
path = os.path.join(folder, "repo")
|
||||
if not os.path.exists(path):
|
||||
repo(path, git)
|
||||
head = subprocess.run([git, "-C", path, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip()
|
||||
found["repo"] = (path, head)
|
||||
return found
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Long editing and switching in ElyDoc, checked as it goes."""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import bench
|
||||
|
||||
W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
||||
|
||||
|
||||
def words(path):
|
||||
"""The saved text, read without LibreOffice; a broken file raises."""
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
body = ET.fromstring(archive.read("word/document.xml"))
|
||||
# Runs join without spaces; a word may span two.
|
||||
paragraphs = ("".join(node.text or "" for node in p.iter(W + "t")) for p in body.iter(W + "p"))
|
||||
return " ".join(paragraphs).split()
|
||||
|
||||
|
||||
def check(paths, n):
|
||||
"""What the saved files get wrong after `n` cycles."""
|
||||
a, b = words(paths["a.docx"]), words(paths["b.docx"])
|
||||
problems = [f"a lacks A{k}" for k in range(1, n + 1) if f"A{k}" not in a]
|
||||
problems += [f"b keeps B{k}" for k in range(1, n + 1) if f"B{k}" in b]
|
||||
if any(word.startswith("A") and word[1:].isdigit() for word in b):
|
||||
problems.append("b holds a's text")
|
||||
return problems
|
||||
|
||||
|
||||
def saves(app):
|
||||
"""The app's last save lines, for a failure."""
|
||||
return [line[-90:] for line in app.lines() if "save" in line][-6:]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--minutes", type=float, default=480)
|
||||
args = parser.parse_args()
|
||||
if sys.platform != "darwin":
|
||||
sys.exit("SKIP: the soak drives macOS windows")
|
||||
work = tempfile.mkdtemp()
|
||||
bench.tools(work)
|
||||
fixture = os.path.join(bench.ROOT, "tests", "fixtures", "office", "t.docx")
|
||||
paths = {name: os.path.join(work, name) for name in ["a.docx", "b.docx"]}
|
||||
for path in paths.values():
|
||||
shutil.copy(fixture, path)
|
||||
app = bench.App("ely-doc", os.path.join(work, "home"), list(paths.values()))
|
||||
def save():
|
||||
"""Saves through the menu: a keystroke can be lost."""
|
||||
bench.sh("osascript", "-e", f'tell application "System Events" to tell (first process whose unix id is {app.process.pid}) '
|
||||
'to click menu item "Save" of menu "File" of menu bar 1')
|
||||
|
||||
def press(title):
|
||||
"""Chooses a tab, then puts the keyboard in its document."""
|
||||
bench.sh(os.path.join(work, "ax-press"), str(app.process.pid), title, "AXRadioButton")
|
||||
time.sleep(0.5)
|
||||
area = next(line for line in app.ax().splitlines() if line.startswith("AXTextArea"))
|
||||
x, y = area.rsplit("at=", 1)[1].split(",")
|
||||
bench.sh(os.path.join(work, "click"), x, y)
|
||||
# The click may land mid-word; typing goes at the end.
|
||||
bench.keys("key code 119 using command down")
|
||||
time.sleep(0.3)
|
||||
|
||||
try:
|
||||
app.wait("opened b.docx")
|
||||
app.front()
|
||||
end, n, memory = time.monotonic() + args.minutes * 60, 0, []
|
||||
while time.monotonic() < end:
|
||||
n += 1
|
||||
press("a.docx")
|
||||
bench.keys(f'keystroke " A{n} "')
|
||||
save()
|
||||
press("b.docx")
|
||||
bench.keys(f'keystroke "B{n}"')
|
||||
time.sleep(0.3)
|
||||
bench.keys('keystroke "z" using command down')
|
||||
save()
|
||||
time.sleep(0.5)
|
||||
if n % 10 == 0:
|
||||
# Saves finish in their own time; give them ten seconds.
|
||||
for _ in range(40):
|
||||
problems = check(paths, n)
|
||||
if not problems:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if problems:
|
||||
raise RuntimeError(f"cycle {n}: {problems[:5]}; a ends {words(paths['a.docx'])[-8:]}; log: {saves(app)}")
|
||||
memory.append(app.memory_mb())
|
||||
print(f"cycle {n}: ok, {memory[-1]:.0f} MB", flush=True)
|
||||
finally:
|
||||
app.stop()
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
print(f"ok: {n} cycles in {args.minutes} min; memory MB {memory[0]:.0f} → {memory[-1]:.0f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user