Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 6e566db1a7 Test: stop the server the way each platform really does
On Windows, terminate() is TerminateProcess (a hard kill, exit 1, no cleanup);
the app stops the server there by closing stdin. The staging-folder test now
stops it by closing stdin on every platform, and also by SIGTERM off Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:07:09 +11:00
saphidandClaude Opus 5.5 db9bee2903 Server: clear in-flight staging folders on the way out, and a dead server's at start
Leaving through os._exit skips the weakref finalizers that clean up a
TemporaryDirectory still in use by a background thread, so quitting during
a patched VR APK transfer left the APK behind (and likewise a file staged
for the assistant, or a half-downloaded app index in the cache folder).

Those folders now carry the server's PID (frame-vr-, frame-agent-, and
.download- in the apk-sources cache folder), like the web-install and title
staging folders already did. sweep_tmp covers all five; it still runs at
start for dead servers' folders, and with own=True at the end of the
shutdown cleanup for this server's. The exit comment now says which exit
work is skipped and why that is safe.

Tests: the sweep test covers every prefix and own=True; a new server test
stops a server with in-flight folders and checks they are gone, and that a
dead server's are removed at the next start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:58:44 +11:00
saphidandClaude Opus 5.5 b25855d13a Server: leave without interpreter teardown after a clean stop (SIGTERM segfault)
On Linux with Python 3.13 (GitHub's ubuntu-latest runner, CPython 3.13.16),
about 1 stop in 100 crashed the server with SIGSEGV. A native backtrace
taken at the crash shows background threads inside OpenSSL
(X509_STORE_set_default_paths_ex, called from _ssl while the app-index
download threads build their TLS context) while the main thread was already
in exit(), where libcrypto's own atexit cleanup frees the state those
threads are using.

After the shutdown cleanup has run, the server now flushes stdout/stderr and
calls os._exit(0). Daemon threads (index downloads, stdin watcher,
telemetry, contact, headset link, request handlers) cannot be stopped
promptly, and nothing in the server registers atexit work; the OS frees the
one-server lock with the process. The stop test now runs its scenario five
times with faulthandler on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:41:38 +11:00
Alex Southwell d58a926257 Merge pull request #76 from saphid/fix/publish-release-draft
Releases: publish drafts through REST and link update.json to the tag page
2026-10-08 18:57:25 +11:00
Alex Southwell 9292ce0a4c Merge pull request #74 from saphid/fix/catalog-arm64-only
Android installs: say which APK to get on a wrong ABI; wrong-file failures don't mark an app broken
2026-10-08 18:54:04 +11:00
saphidandClaude Opus 5.5 f5f3a1f9ca Releases: exact draft match and a fixed release page (review fixes)
- publish-release.sh: the fallback takes only an untagged-... draft titled
  exactly "Frame Control X.Y.Z" (optionally ": subtitle"); a pre-release's
  draft ("9.8.7-rc.1") or one bound to another tag is refused.
- updater.js: ignore the page in update.json/the API entirely and always
  link to releases/tag/v<version> built from the validated version, so a
  path like tag/..\..\other/repo can't reach shell.openExternal.
- Tests for both, including backslash and %2e%2e traversal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:50:00 +11:00
saphidandClaude Opus 5.5 a46c28d6c8 Drop compat reports in search from this change (moved to #77)
Review (T3 task pr74-review-r1, gpt-6.1-sol) found that search blocked on
frame_compat_db.load() after a cache expiry (up to ~40 s with an unreachable
database, or an untimed Keychain lookup without a key), and that the verdict
showed only in the detail view, not on cards. Revert ui/apk_sources/search.py,
ui/server.py, ui/index.html and their tests to main; keep the wrong-ABI error
text, the reports.py wrong-file filter and the ABI-selection tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:46:35 +11:00
saphidandClaude Opus 5.5 a41f635a7c Releases: publish drafts through REST and link update.json to the tag page
publish-release.sh took update.json's "page" from the draft's url, which is
releases/tag/untagged-... and dies on publishing; 0.4.0's manifest shipped
that dead link. It also looked the draft up with `gh release view <tag>`,
which reports "release not found" while a draft's tag_name still reads
untagged-... (and uses rate-limited GraphQL).

- publish-release.sh: REST only. Checks the tag exists, finds the release by
  tag_name or else the one untagged draft titled "Frame Control X.Y.Z",
  keeps the 8-installer digest check, replaces update.json via the uploads
  API, then PATCHes tag_name/draft/prerelease/make_latest. Adds --dry-run.
- updater.js: trust only this repo's releases/tag/<tag> pages (never
  untagged-...); otherwise link to releases/tag/v<version>.
- release.yml: one draft job before the matrix (no racing creates), with
  --verify-tag and tag_name set explicitly.
- Tests: tests/test_publish_release.py with a stand-in gh (tests/fakegh/gh),
  and an updater test for the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:41:37 +11:00
saphidandClaude Opus 5.5 88a2fe6728 Android store: say which APK to get on a wrong ABI; show compat reports in search
PostHog (0.4.0): Grayjay installs failed twice with apk_wrong_abi, once with
an armeabi-v7a file and once with an x86_64 file. Grayjay's own site offers
one APK per ABI; the FUTO F-Droid repo's Grayjay 391 is universal and does
contain arm64-v8a, and the catalogue and repo search already drop builds
without arm64-v8a, so the wrong files did not come from those paths.

- The wrong-ABI error now tells the user to download the arm64-v8a (or
  arm64, or universal) APK instead, so they don't guess again.
- An install_failed report caused by a wrong-ABI (or too-new-Android) file
  no longer rates the whole app as broken in the catalogue.
- Search results and details carry the compatibility reports the catalogue
  uses: "Reported not working on the Frame" (a warning, not a block) or
  "Works on the Frame". A report never overrides a hard blocker.
- Tests: per-ABI split builds (offer and download the arm64 one), the new
  error text and its telemetry category, wrong-file reports, and search
  verdicts from reports, with the database hook off by default in tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:37:08 +11:00
16 changed files with 531 additions and 60 deletions

No files matched your search

+25 -3
View File
@@ -14,7 +14,31 @@ permissions:
contents: write
jobs:
# One job makes the draft, before the builds: three matrix jobs each running
# "view || create" could race and make duplicate drafts. The draft is tied to
# the pushed tag (--verify-tag, then tag_name set explicitly), so
# scripts/publish-release.sh and `gh release upload` find it by tag.
draft:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
if [ -z "$id" ]; then
gh release create "$tag" --draft --verify-tag --title "Frame Control ${tag#v}" --notes ""
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\") | .id" | head -n 1)
fi
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
build:
needs: draft
# Still runs for pull requests and manual runs, where the draft job is skipped.
if: ${{ !failure() && !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -46,9 +70,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
gh release upload "$tag" ${{ matrix.files }} --clobber
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
- uses: actions/upload-artifact@v4
with:
name: frame-control-${{ matrix.os }}
+14
View File
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
An install_failed report saying the file had no arm64-v8a build (or needed a
newer Android) is about that one APK file, often the wrong per-ABI download of
an app that has an arm64 build, so it says nothing about the app and is left out.
"""
import re
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
def about_app(r):
"""False for reports about one wrong APK file rather than the app itself."""
return not (r.get('result') == 'install_failed' and not r.get('rating')
and FILE_FAULT.search(r.get('notes') or ''))
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
reports = [r for r in reports or () if about_app(r)]
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
+21
View File
@@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () =>
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
});
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
const { fromManifest, fromApi } = require("../updater");
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
assert.strictEqual(page(tagPage), tagPage);
// 0.4.0's real update.json: the draft's address, a 404 once published.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
assert.strictEqual(page(undefined), tagPage);
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
// Paths that normalize to another repository.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
});
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
const { prepare } = require("../updater");
const release = { version: "0.3.1", assets: [] };
+18 -5
View File
@@ -106,12 +106,24 @@ async function getJson(url, headers) {
return JSON.parse(body);
}
// The release page the banner links to. update.json for 0.4.0 carried the draft's
// address (releases/tag/untagged-...), which is dead once the release is published,
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
// normalizes to another repository) before shell.openExternal. So the given page is
// ignored: the link is always this repository's tag page, built from a valid version.
function releasePage(version) {
const v = parseVersion(version);
if (!v) return RELEASES;
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
}
// update.json and the API's release both become { version, notes, page, assets }.
function fromManifest(m) {
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
page: m.page || RELEASES,
const version = String(m.version).replace(/^v/i, "");
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
return { version, notes: String(m.notes || "").slice(0, 4000),
page: releasePage(version),
// Assets always come from this repository's release, whatever the manifest says.
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
size: a.size, digest: a.digest || null })) };
@@ -119,8 +131,9 @@ function fromManifest(m) {
function fromApi(r) {
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
page: r.html_url || RELEASES,
const version = String(r.tag_name || "").replace(/^v/i, "");
return { version, notes: String(r.body || "").slice(0, 4000),
page: releasePage(version),
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
digest: a.digest || null })) };
}
+11 -4
View File
@@ -24,13 +24,20 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
```
The script checks that all eight installers are attached, each with the
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
notes and each installer's digest), then publishes the release and marks it
latest. From then on, running copies see the update. They check about 8
The script checks that the tag is on GitHub and that all eight installers
are attached, each with the SHA-256 digest GitHub records. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
uses only the REST API: `gh release view` can't find a draft whose
`tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always
`releases/tag/<tag>`, because a draft's own address (`releases/tag/untagged-…`)
stops working once it's published. From then on, running copies see the update. They check about 8
seconds after starting, then every 6 hours, and anyone can use **Check for
Updates…** (the app menu on macOS, the Help menu elsewhere).
+74 -16
View File
@@ -3,23 +3,65 @@
# (docs/releasing.md). Checks every installer is attached with a SHA-256
# digest first, since the app's updater refuses assets without one, then
# attaches update.json, the manifest the updater reads.
# Usage: scripts/publish-release.sh v0.4.0
# Usage: scripts/publish-release.sh [--dry-run] v0.4.0
#
# Everything goes through the REST API (gh api), not `gh release view/edit`:
# those look a draft up by its tag, and a draft can show tag_name
# "untagged-..." until it's published, so they report "release not found"
# (and GraphQL is often rate-limited). A draft's html_url is an untagged-...
# link that dies on publishing, so update.json's page is built from the tag.
set -eu
tag="${1:?usage: $0 vX.Y.Z}"
dry=""
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
tag="${1:?usage: $0 [--dry-run] vX.Y.Z}"
repo=saphid/frame-control
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
page="https://github.com/$repo/releases/tag/$tag"
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
# Publishing sets tag_name; if the tag didn't exist GitHub would create it on
# the default branch, which isn't what was built and tested.
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
# Every release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
# The release whose tag_name is the tag; failing that, the one untagged-... draft
# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle".
python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF'
import json, re, sys
tag, path = sys.argv[1], sys.argv[2]
rels = [json.loads(line) for line in open(path) if line.strip()]
hits = [r for r in rels if r.get("tag_name") == tag]
if not hits:
# Exactly this version: "Frame Control 0.4.0", or that followed by ": <subtitle>".
# Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag.
title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S)
hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-")
and title.fullmatch(r.get("name") or "")]
if len(hits) != 1:
why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits))
sys.exit("found %s for %s; expected one draft" % (why, tag))
r = hits[0]
if not r.get("draft"):
print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
missing=""
for name in $expected; do
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
d=json.load(sys.stdin); n=sys.argv[1]
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
digest=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1])); n=sys.argv[2]
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name")
case "$digest" in
sha256:*) echo "ok $name" ;;
absent) echo "MISSING $name"; missing=1 ;;
@@ -30,16 +72,32 @@ done
# update.json: what running copies read (app/updater.js), from github.com's
# latest/download link rather than the rate-limited REST API.
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
d=json.load(sys.stdin)
names=set(sys.argv[1].split())
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
import json, sys
d = json.load(open(sys.argv[1]))
tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split())
print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000],
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
for a in d["assets"] if a["name"] in names]}, indent=1))
EOF
old=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1]))
print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json")
if [ -n "$dry" ]; then
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
cat "$tmp/update.json"
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
exit 0
fi
for asset in $old; do
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
done
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
echo "ok update.json"
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
echo "published $tag; running copies will offer it at their next check"
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
echo "running copies will offer $tag at their next check"
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py. Serves
the releases in $FAKEGH_RELEASES (a JSON list, drafts included) and the tags in
$FAKEGH_TAGS (space-separated); an upload's body is written to $FAKEGH_UPLOAD.
Every call is appended to $FAKEGH_LOG as a JSON line. Anything else fails, so the
script under test can't fall back to `gh release ...` (GraphQL) unnoticed."""
import json
import os
import sys
args = sys.argv[1:]
with open(os.environ["FAKEGH_LOG"], "a") as f:
f.write(json.dumps(args) + "\n")
if not args or args[0] != "api":
sys.exit("fakegh: only `gh api` is supported: %r" % args)
method, endpoint, fields, inp, i = "GET", None, {}, None, 1
while i < len(args):
a = args[i]
if a == "-X":
method = args[i + 1]; i += 2
elif a in ("-f", "-F"):
k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2
elif a == "--input":
inp = args[i + 1]; i += 2
elif a in ("-H", "--jq"):
i += 2
elif a.startswith("-"):
i += 1
elif endpoint is None:
endpoint = a; i += 1
else:
sys.exit("fakegh: unexpected argument %r" % a)
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
tag = endpoint.rsplit("/", 1)[1]
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
sys.exit("gh: Not Found (HTTP 404)")
print(json.dumps({"ref": "refs/tags/" + tag}))
elif method == "GET" and endpoint.startswith(repo + "releases?"):
for r in releases: # what --jq '.[]' prints
print(json.dumps(r))
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
pass
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
dst.write(src.read())
print("{}")
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
print("published %s at https://github.com/saphid/frame-control/releases/tag/%s"
% (fields.get("tag_name"), fields.get("tag_name")))
else:
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
+30
View File
@@ -256,6 +256,36 @@ class Repositories(unittest.TestCase):
self.assertEqual(result['icon'], URL + 'icons/legacy.1.png')
self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png'])
def test_per_abi_builds_offer_and_download_the_arm64_one(self):
import hashlib
def build(code, name, abis):
self.files[name] = name.encode()
return {'manifest': {'versionName': '391', 'versionCode': code, 'usesSdk': {'minSdkVersion': 28},
'nativecode': abis},
'file': {'name': '/' + name, 'sha256': hashlib.sha256(name.encode()).hexdigest(), 'size': code},
'added': 0}
# One APK per ABI under different codes (the x86_64 one highest, as F-Droid often does),
# plus a universal and an arm64-only build sharing a code.
builds = [build(3911, 'app-armeabi-v7a.apk', ['armeabi-v7a']), build(3914, 'app-x86_64.apk', ['x86_64']),
build(3913, 'app-x86.apk', ['x86']),
build(3912, 'app-universal.apk', ['arm64-v8a', 'armeabi-v7a', 'x86_64']),
build(3912, 'app-arm64-v8a.apk', ['arm64-v8a'])]
raw = self.root / 'splits.json'
raw.write_text(json.dumps({'packages': {'com.futo.platformplayer': {
'metadata': {'name': {'en-US': 'Grayjay'}},
'versions': {str(i): b for i, b in enumerate(builds)}}}}))
source = {'id': 'test', 'url': URL}
app = fdroid._reduce(raw, source)['com.futo.platformplayer']
self.assertEqual([v['name'] for v in app['versions']], ['/app-arm64-v8a.apk', '/app-universal.apk'])
self.assertEqual((app['version_code'], app['abis']), (3912, ['arm64-v8a']))
with patch.object(fdroid, 'details', return_value=app):
for code in (None, 3912):
fdroid.download(source, 'com.futo.platformplayer', version_code=code)
self.assertTrue(self.fetch_mock.call_args[0][0].endswith('/app-arm64-v8a.apk'))
with self.assertRaises(SourceError): # the x86_64 build is never offered
fdroid.download(source, 'com.futo.platformplayer', version_code=3914)
def test_v2_legacy_screenshot_keys_and_limit(self):
meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]},
'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}}
+29
View File
@@ -184,6 +184,35 @@ class VersionsTest(unittest.TestCase):
info['abis'] = ['armeabi-v7a']
self.assertIn('no arm64-v8a build', versions.describe(info))
def test_wrong_abi_error_says_which_file_to_get(self):
import frame_telemetry
for abis in (['armeabi-v7a'], ['x86_64']): # the two per-ABI Grayjay files users tried
info = {'label': 'Grayjay', 'min_sdk': 28, 'abis': abis}
with self.assertRaises(frame_android.FrameError) as error:
frame_android.check_installable(info)
message = str(error.exception)
self.assertIn('no arm64-v8a build (%s)' % abis[0], message)
self.assertIn('download the APK marked arm64-v8a', message)
self.assertEqual(frame_telemetry.categorize(message)[0], 'apk_wrong_abi')
frame_android.check_installable({'label': 'Universal', 'min_sdk': 28,
'abis': ['arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64']})
def test_wrong_file_reports_do_not_rate_the_app(self):
reports = frame_catalog.reports
wrong_file = {'package': 'org.example.app', 'version': '391', 'result': 'install_failed',
'notes': 'Example has no arm64-v8a build (x86_64); Lepton is 64-bit ARM only',
'date': '2026-10-01T10:00:00'}
self.assertIsNone(reports.verdict([wrong_file]))
app = frame_catalog.catalog_build.finalize({'pr': 'likely', 'pw': ['No known blockers']}, [wrong_file])
self.assertEqual((app['r'], app['t']), ('likely', False)) # the prediction stands
# A real installer failure, a crash or a person's rating still counts.
installer = dict(wrong_file, notes='INSTALL_FAILED_INVALID_APK')
self.assertEqual(reports.verdict([installer])[0], 'no')
crash = dict(wrong_file, result='crashes', notes=None, date='2026-10-02')
self.assertEqual(reports.verdict([wrong_file, crash])[0], 'no')
rated = dict(wrong_file, rating='works', date='2026-10-03')
self.assertEqual(reports.verdict([wrong_file, rated])[0], 'works')
def test_install_resolves_index_hash(self):
versions.alternatives('org.example.app')
with patch.object(versions, 'alternatives', side_effect=AssertionError('recomputed')), \
+110
View File
@@ -0,0 +1,110 @@
"""scripts/publish-release.sh against a stand-in gh (tests/fakegh/gh): finds the draft
through the REST API even when its tag_name still says untagged-..., refuses
missing installers or digests, writes update.json's page from the tag, and
publishes with one PATCH. Nothing here talks to GitHub.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "publish-release.sh"
FAKEGH = ROOT / "tests" / "fakegh"
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe",
"Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage",
"Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb",
"Frame-Control-linux-arm64.deb"]
def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", rid=42, assets=None):
if assets is None:
assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i}
for i, n in enumerate(INSTALLERS)]
return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False,
"body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name,
"assets": assets}
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7"):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
return p, log, upload
def test_publishes_an_untagged_draft_by_title_with_the_tag_page(self):
old = {"id": 7, "name": "update.json", "size": 1, "digest": None}
rel = draft(assets=draft()["assets"] + [old])
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(upload["page"], "https://github.com/saphid/frame-control/releases/tag/v9.8.7")
self.assertEqual(upload["version"], "9.8.7")
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
patch = next(c for c in log if "PATCH" in c)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
self.assertIn(f, patch)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
def test_an_untagged_draft_may_carry_a_subtitle(self):
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(upload["version"], "9.8.7")
def test_prefers_the_release_whose_tag_name_matches(self):
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1),
draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2")
def test_refuses_missing_or_unhashed_installers(self):
assets = draft()["assets"][1:]
assets[0] = dict(assets[0], digest=None)
p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("MISSING Frame-Control-mac-arm64.dmg", p.stdout)
self.assertIn("NO HASH Frame-Control-mac-arm64.zip", p.stdout)
self.assertIsNone(upload)
self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log))
def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self):
p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("2 releases", p.stderr)
p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("no release", p.stderr)
# A pre-release's draft, or one bound to an unrelated tag, is never taken for v9.8.7.
for rel in (draft(name="Frame Control 9.8.7-rc.1"), draft(name="Frame Control 9.8.7.1"),
draft(tag_name="kdeconnect-frame-1"), draft(tag_name="")):
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertNotEqual(p.returncode, 0, rel)
self.assertIn("no release", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("PATCH" in c for c in log))
p, log, _ = self.run_script([draft()], "v9.8.7", tags="")
self.assertNotEqual(p.returncode, 0)
self.assertIn("push it first", p.stderr)
def test_dry_run_changes_nothing(self):
p, log, upload = self.run_script([draft()], "--dry-run", "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertIn('"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7"', p.stdout)
self.assertIsNone(upload)
self.assertTrue(all("-X" not in c for c in log))
if __name__ == "__main__":
unittest.main()
+72 -9
View File
@@ -10,6 +10,7 @@ import http.client
import io
import json
import os
import shutil
import socket
import struct
import subprocess
@@ -287,15 +288,77 @@ class OneServer(unittest.TestCase):
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit."""
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit,
and later, now and then, crash it (SIGSEGV) while background threads were still
loading TLS certificates. Run a few times: that crash came about 1 run in 100."""
for attempt in range(5):
with self.subTest(attempt=attempt):
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid", "PYTHONFAULTHANDLER": "1"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
proc.stdin.close()
proc.stdout.close()
shutil.rmtree(env["FRAME_CONTROL_DATA_DIR"], ignore_errors=True)
def test_staging_folders_cleared_when_stopped_mid_transfer(self):
"""The server leaves without interpreter teardown, so TemporaryDirectory cleanup
doesn't run for work still in progress: its own staging folders go on the way out,
and a dead server's at the next start."""
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
home = tempfile.mkdtemp(prefix="frame-stop-home-")
self.addCleanup(shutil.rmtree, home, ignore_errors=True)
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": os.path.join(home, "data"),
"FRAME_ALIAS": "frame-control-test.invalid", "HOME": home, "XDG_CACHE_HOME": os.path.join(home, ".cache"),
"LOCALAPPDATA": home, "APPDATA": home}
index_dir = Path(subprocess.run(
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_host; "
"print(frame_host.cache_dir('apk-sources'))", str(ROOT / "ui")],
env=env, capture_output=True, text=True, check=True).stdout.strip())
index_dir.mkdir(parents=True)
tmp = Path(tempfile.gettempdir())
def staged(folder, prefix, pid):
d = Path(tempfile.mkdtemp(prefix=f"{prefix}{pid}-", dir=folder))
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
(d / "app.apk").write_bytes(b"\0" * 4096)
return d
# Stopped as the app stops it: by closing stdin (the only way on Windows,
# where terminate() is a hard kill) and, elsewhere, by SIGTERM too.
for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []):
with self.subTest(stop=stop):
left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)]
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
self.assertIn("Frame Control on", proc.stdout.readline())
self.assertEqual([d for d in left_by_dead if d.exists()], [])
in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid),
staged(index_dir, ".download-", proc.pid)]
if stop == "stdin":
proc.stdin.close()
else:
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
self.assertEqual([d for d in in_flight if d.exists()], [])
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
proc.stdin.close()
proc.stdout.close()
class ArtworkSettings(unittest.TestCase):
+22 -11
View File
@@ -386,17 +386,28 @@ class ServerJobs(unittest.TestCase):
def test_dead_servers_leftovers_swept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
# Downloads and title staging (unzipped titles) are both swept.
for prefix in (self.server.WEB_TMP_PREFIX, self.server.frame_titles.TMP_PREFIX):
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-")
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-")
try:
self.server.sweep_tmp()
self.assertFalse(os.path.exists(gone), prefix)
self.assertTrue(os.path.exists(live), prefix)
finally:
shutil.rmtree(gone, ignore_errors=True)
shutil.rmtree(live, ignore_errors=True)
s = self.server
with tempfile.TemporaryDirectory() as cache, \
mock.patch.object(s.frame_host, "cache_dir", lambda *parts: Path(cache).joinpath(*parts)):
# Downloads, title staging, patched VR APKs, files staged for the
# assistant, and app-index downloads (in the cache folder).
places = s._tmp_places()
self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX,
s.apk_fdroid.TMP_PREFIX})
for folder, prefix in places:
folder.mkdir(parents=True, exist_ok=True)
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-", dir=folder)
try:
s.sweep_tmp()
self.assertFalse(os.path.exists(gone), prefix)
self.assertTrue(os.path.exists(live), prefix)
s.sweep_tmp(own=True) # on the way out: this server's own go too
self.assertFalse(os.path.exists(live), prefix)
finally:
shutil.rmtree(gone, ignore_errors=True)
shutil.rmtree(live, ignore_errors=True)
def test_temp_dir_failure_ends_the_job(self):
job, dispatch = self.run_job(mkdtemp_error=OSError("disk full"))
+2 -1
View File
@@ -27,6 +27,7 @@ from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
_LOCK = threading.RLock() # settings only; never held while downloading
_load_locks = {}
_refreshing = {} # source id -> background refresh thread
@@ -530,7 +531,7 @@ def _load(source, force=False):
return found[:2]
cache.parent.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-', dir=str(cache.parent)) as tmp:
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
+3 -1
View File
@@ -9,6 +9,8 @@ import subprocess
import threading
import time
TMP_PREFIX = 'frame-agent-' # then the server's PID, so server.sweep_tmp can clear a stopped run's
class Approvals:
def __init__(self):
@@ -109,7 +111,7 @@ def call(server, body):
if name == 'send_file':
# Stage the reviewed bytes before the existing transfer helper reads them.
import tempfile
with tempfile.TemporaryDirectory(prefix='frame-agent-') as tmp:
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
source = Path(action['arguments']['path'])
with source.open('rb') as stream:
data = stream.read(16 * 1024**2 + 1)
+7 -2
View File
@@ -23,6 +23,7 @@ from frame_apk_sign import repack
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
TMP_PREFIX = 'frame-vr-' # then the server's PID, so server.sweep_tmp can clear a stopped run's patched APK
APPS_DIR = 'Applications/Android' # relative to the Frame's $HOME
COMPAT = '.local/share/Steam/steamapps/compatdata'
SHADERS = '.local/share/Steam/steamapps/shadercache'
@@ -104,7 +105,11 @@ def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30:
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
if info['abis'] and 'arm64-v8a' not in info['abis']:
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
# Sites that offer one APK per ABI (Grayjay: arm64-v8a, armeabi-v7a, x86, x86_64,
# universal) leave the choice to the user; say which file to fetch instead.
raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only. "
"This file is for other devices: download the APK marked arm64-v8a "
"(or arm64, or universal) and install that instead")
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
@@ -163,7 +168,7 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
with _install_lock:
if add or info['repairable']:
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
patched = os.path.join(tmp, 'app.apk')
info['patched'] = patch(apk_path, patched, add)['patched']
info['launchable'] = True
+38 -8
View File
@@ -45,6 +45,7 @@ import frame_agent # noqa: E402
import frame_assistant # noqa: E402
import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
from apk_sources import fdroid as apk_fdroid # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
@@ -1922,23 +1923,36 @@ def _pid_alive(pid):
return True
def sweep_tmp():
"""Delete download and title staging folders left by a server killed mid-install.
def _tmp_places():
"""Where each kind of staging folder goes, and its name before the server's PID."""
tmp = Path(tempfile.gettempdir())
return [(tmp, WEB_TMP_PREFIX), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
(tmp, frame_agent.TMP_PREFIX), (frame_host.cache_dir("apk-sources"), apk_fdroid.TMP_PREFIX)]
Folders carry the server's PID, so only a dead server's are taken.
def sweep_tmp(own=False):
"""Delete staging folders (downloads, unzipped titles, patched APKs, staged files,
app-index downloads) left by a server that stopped mid-way.
Folders carry the server's PID, so only a dead server's are taken; own=True (on
the way out, see main) takes this server's too.
"""
for prefix in (WEB_TMP_PREFIX, frame_titles.TMP_PREFIX):
for d in Path(tempfile.gettempdir()).glob(f"{prefix}*"):
_sweep_one(prefix, d)
for folder, prefix in _tmp_places():
try:
found = list(folder.glob(f"{prefix}*"))
except OSError:
continue
for d in found:
_sweep_one(prefix, d, own)
def _sweep_one(prefix, d):
def _sweep_one(prefix, d, own=False):
m = re.fullmatch(re.escape(prefix) + r"(\d+)-.*", d.name)
if not m:
return
pid = int(m[1])
try:
if pid != os.getpid() and not _pid_alive(pid) and d.is_dir():
if (own if pid == os.getpid() else not _pid_alive(pid)) and d.is_dir():
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
@@ -2742,6 +2756,22 @@ def main():
if proc.poll() is None:
proc.terminate()
_purge_titles(now=float("inf")) # unconfirmed title uploads
# Staging folders of work still running (a patched APK mid-copy, an index
# download): leaving below skips the cleanup their TemporaryDirectory would
# get at interpreter exit. sweep_tmp at the next start catches any missed.
sweep_tmp(own=True)
# Stopped as asked, and everything above is cleaned up. Leave now, without
# Python's interpreter teardown: daemon threads are still running (app index
# downloads, the stdin watcher, telemetry, the headset link, request handlers)
# and none can be stopped promptly. Tearing the interpreter down under them
# occasionally crashed the process (SIGSEGV, seen on Python 3.13 on Linux):
# OpenSSL's exit cleanup freed state those threads were using. That teardown
# also runs atexit handlers and weakref finalizers; nothing here registers
# atexit work, the only finalizers are TemporaryDirectory cleanups (swept
# above), and the OS frees the one-server lock with the process.
sys.stdout.flush()
sys.stderr.flush()
os._exit(0)
if __name__ == "__main__":