mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-08 19:00:41 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a489e4606 | ||
|
|
0304575e5b | ||
|
|
81bf646b0e | ||
|
|
4f99cd75d0 | ||
|
|
3e814cfa1a | ||
|
|
6e566db1a7 | ||
|
|
db9bee2903 | ||
|
|
b25855d13a |
No files matched your search
@@ -16,8 +16,12 @@ permissions:
|
||||
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.
|
||||
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
|
||||
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
|
||||
# by tag. It is created with the REST API so the id comes straight back: the
|
||||
# release list can lag a fresh draft (v0.4.2's first run found nothing and
|
||||
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
|
||||
# this step against tests/fakegh.
|
||||
draft:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,11 +32,16 @@ jobs:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
# A rerun finds the draft an earlier attempt made.
|
||||
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)
|
||||
# Given a missing tag, GitHub would create it on the default branch.
|
||||
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|
||||
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
|
||||
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
|
||||
-F draft=true -f body="" --jq .id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
|
||||
|
||||
build:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
+43
-11
@@ -1,11 +1,17 @@
|
||||
#!/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."""
|
||||
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
|
||||
tests/test_release_workflow.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. POST .../releases (release.yml's draft step) answers as release
|
||||
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
|
||||
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
|
||||
(raw strings, compact JSON)."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
@@ -14,7 +20,7 @@ with open(os.environ["FAKEGH_LOG"], "a") as f:
|
||||
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
|
||||
method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "-X":
|
||||
@@ -23,7 +29,9 @@ while i < len(args):
|
||||
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"):
|
||||
elif a == "--jq":
|
||||
jq = args[i + 1]; i += 2
|
||||
elif a == "-H":
|
||||
i += 2
|
||||
elif a.startswith("-"):
|
||||
i += 1
|
||||
@@ -34,14 +42,34 @@ while i < len(args):
|
||||
|
||||
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
|
||||
repo = "repos/saphid/frame-control/"
|
||||
|
||||
|
||||
def answer(response, fallback):
|
||||
"""Print response through --jq when jq is here, else the fixed fallback text."""
|
||||
if jq and shutil.which("jq"):
|
||||
out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
||||
sys.stdout.write(out.stdout)
|
||||
if out.returncode:
|
||||
sys.exit("fakegh: jq failed: " + out.stderr)
|
||||
else:
|
||||
print(fallback)
|
||||
|
||||
|
||||
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))
|
||||
# The fallback is what --jq '.[]' prints.
|
||||
answer(releases, "\n".join(json.dumps(r) for r in releases))
|
||||
elif method == "POST" and endpoint == repo + "releases":
|
||||
new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
|
||||
"name": fields.get("name"), "draft": fields.get("draft") == "true",
|
||||
"body": fields.get("body"), "assets": [],
|
||||
"html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
|
||||
answer(new, json.dumps(new))
|
||||
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
|
||||
pass
|
||||
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
|
||||
@@ -49,7 +77,11 @@ elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + re
|
||||
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")))
|
||||
rid = endpoint[len(repo + "releases/"):]
|
||||
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
|
||||
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
|
||||
"published %s at %s" % (fields.get("tag_name"), page))
|
||||
else:
|
||||
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""The "Create the draft release" step of .github/workflows/release.yml, taken out of
|
||||
the workflow and run with bash (as Actions runs it) against a stand-in gh
|
||||
(tests/fakegh/gh): a new draft's id comes straight from the POST even when the
|
||||
release list doesn't show it yet (v0.4.2's first run), a rerun reuses the draft
|
||||
found by tag_name, and a tag that isn't on GitHub stops it. 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 shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
|
||||
FAKEGH = ROOT / "tests" / "fakegh"
|
||||
STEP = "- name: Create the draft release"
|
||||
|
||||
|
||||
def step_script():
|
||||
"""The step's `run: |` block, dedented."""
|
||||
lines = WORKFLOW.read_text().splitlines()
|
||||
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
|
||||
while lines[i].strip() != "run: |":
|
||||
i += 1
|
||||
indent = len(lines[i]) - len(lines[i].lstrip())
|
||||
body = []
|
||||
for line in lines[i + 1:]:
|
||||
if line.strip() and len(line) - len(line.lstrip()) <= indent:
|
||||
break
|
||||
body.append(line)
|
||||
return textwrap.dedent("\n".join(body)).strip() + "\n"
|
||||
|
||||
|
||||
def release(rid, tag_name, name="Frame Control 9.8.7", draft=True):
|
||||
return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []}
|
||||
|
||||
|
||||
# The step only runs on ubuntu-latest. On Windows `bash` is often the WSL launcher
|
||||
# (with no distribution on GitHub's runners), not a bash that can run tests/fakegh.
|
||||
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
|
||||
"needs a POSIX bash and jq (for gh --jq); the step runs on ubuntu only")
|
||||
class DraftStep(unittest.TestCase):
|
||||
def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "step.sh").write_text(step_script())
|
||||
(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"), GITHUB_REF_NAME=tag, GH_REPO="saphid/frame-control")
|
||||
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
|
||||
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
universal_newlines=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||
return p, log
|
||||
|
||||
def posts(self, log):
|
||||
return [c for c in log if c[1:4] == ["-X", "POST", "repos/saphid/frame-control/releases"]]
|
||||
|
||||
def patches(self, log):
|
||||
return [c for c in log if c[1:3] == ["-X", "PATCH"]]
|
||||
|
||||
def test_new_draft_uses_the_id_the_post_returns(self):
|
||||
# The list doesn't show the fresh draft (it never does in the fake): v0.4.2's case.
|
||||
p, log = self.run_step([])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
[post] = self.posts(log)
|
||||
for f in ("tag_name=v9.8.7", "name=Frame Control 9.8.7", "draft=true"):
|
||||
self.assertIn(f, post)
|
||||
self.assertIn(["api", "repos/saphid/frame-control/git/ref/tags/v9.8.7"], log)
|
||||
[patch] = self.patches(log)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/9001")
|
||||
self.assertIn("tag_name=v9.8.7", patch)
|
||||
self.assertIn("release 9001 tag_name v9.8.7", p.stdout)
|
||||
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...`
|
||||
|
||||
def test_a_rerun_reuses_the_draft_with_the_tag(self):
|
||||
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6"), release(42, "v9.8.7")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(self.posts(log), [])
|
||||
[patch] = self.patches(log)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
||||
|
||||
def test_other_releases_alone_mean_a_new_draft(self):
|
||||
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
|
||||
release(8, "v9.8.70", "Frame Control 9.8.70")])
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(len(self.posts(log)), 1)
|
||||
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/9001")
|
||||
|
||||
def test_stops_when_the_tag_is_not_on_github(self):
|
||||
p, log = self.run_step([], tags="")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("tag v9.8.7 isn't on GitHub", p.stderr)
|
||||
self.assertEqual(self.posts(log), [])
|
||||
self.assertEqual(self.patches(log), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+72
-9
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+2
-1
@@ -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'
|
||||
@@ -200,7 +201,7 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
|
||||
info['xr_layer_missing'] = True
|
||||
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')
|
||||
try:
|
||||
info['patched'] = patch(apk_path, patched, add)['patched']
|
||||
|
||||
+38
-8
@@ -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
|
||||
@@ -1935,23 +1936,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
|
||||
@@ -2757,6 +2771,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__":
|
||||
|
||||
Reference in new issue
Block a user