Fix blocking sync I/O on the event loop across containers, jobs, and xmlrpc
DevPlace CI / test (push) Failing after 26m53s

The container/workspace reachability probes (socket connect + HTTP check)
ran synchronously with real timeouts inside async request handlers and the
live-view relay's 3-4s ticks, freezing the whole event loop whenever a
container wasn't cleanly reachable - the likely cause of the periodic
app-wide stalls. Converted the probe chain (api._port_reachable/_http_probe,
editor_reachable, instance_runtime, provision.editor_ready/view) to real
async I/O and parallelized the admin container/workspace list decorators.

Also fixes: XmlrpcService.on_disable blocked up to 10s on a synchronous
subprocess.wait inside an async method (now matches TelegramService's
async-subprocess pattern); JobService._sweep_expired ran every job kind's
cleanup() - including shutil.rmtree on large directories - synchronously
on every tick, now offloaded via asyncio.to_thread for all job kinds at
once; and several smaller blocking reads/writes on request/service paths
(attachment-to-gitea mirroring, stealth chunked downloads, dbapi/isslop
file reads, job payload/report I/O) moved off the loop thread.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:59:10 +00:00
co-authored by Claude Sonnet 5
parent cc969aa187
commit 4a13415b43
23 changed files with 132 additions and 104 deletions
+1 -1
View File
@@ -501,7 +501,7 @@ async def mirror_attachment_to_gitea(uid):
return None return None
path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "") path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "")
try: try:
data = path.read_bytes() data = await asyncio.to_thread(path.read_bytes)
except OSError as exc: except OSError as exc:
logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc) logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc)
return None return None
+1 -1
View File
@@ -409,7 +409,7 @@ async def container_instance_page(request: Request, uid: str):
"events": store.list_events(inst["uid"]), "events": store.list_events(inst["uid"]),
"schedules": store.list_schedules(inst["uid"]), "schedules": store.list_schedules(inst["uid"]),
"stats": api.instance_stats(inst["uid"]), "stats": api.instance_stats(inst["uid"]),
"runtime": api.instance_runtime(inst), "runtime": await api.instance_runtime(inst),
"can_manage": can_manage, "can_manage": can_manage,
"admin_section": "containers", "admin_section": "containers",
}, },
+6 -5
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import asyncio
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
@@ -31,14 +32,14 @@ from devplacepy.utils import create_notification, generate_uid, not_found, requi
router = APIRouter() router = APIRouter()
def _decorate(rows: list[dict]) -> list[dict]: async def _decorate(rows: list[dict]) -> list[dict]:
owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")} owner_uids = {row.get("workspace_owner_uid") for row in rows if row.get("workspace_owner_uid")}
owners = get_users_by_uids(list(owner_uids)) if owner_uids else {} owners = get_users_by_uids(list(owner_uids)) if owner_uids else {}
project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")} project_uids = {row.get("project_uid") for row in rows if row.get("project_uid")}
projects = get_projects_by_uids(list(project_uids)) if project_uids else {} projects = get_projects_by_uids(list(project_uids)) if project_uids else {}
views = await asyncio.gather(*(provision.view(row) for row in rows))
decorated = [] decorated = []
for row in rows: for row, view in zip(rows, views):
view = provision.view(row)
owner = owners.get(row.get("workspace_owner_uid", "")) or {} owner = owners.get(row.get("workspace_owner_uid", "")) or {}
project = projects.get(row.get("project_uid", "")) or {} project = projects.get(row.get("project_uid", "")) or {}
view["owner_username"] = owner.get("username", "") view["owner_username"] = owner.get("username", "")
@@ -79,7 +80,7 @@ async def admin_workspaces(request: Request):
if not isinstance(admin, dict): if not isinstance(admin, dict):
return admin return admin
context = { context = {
"workspaces": _decorate(_all_workspaces()), "workspaces": await _decorate(_all_workspaces()),
"flags": flags.list_flags(), "flags": flags.list_flags(),
"admin_section": "workspaces", "admin_section": "workspaces",
"user": admin, "user": admin,
@@ -104,7 +105,7 @@ async def admin_workspaces_data(request: Request):
if not isinstance(admin, dict): if not isinstance(admin, dict):
return admin return admin
return JSONResponse( return JSONResponse(
{"workspaces": _decorate(_all_workspaces()), "flags": flags.list_flags()} {"workspaces": await _decorate(_all_workspaces()), "flags": flags.list_flags()}
) )
+3 -1
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import asyncio
import json import json
import logging import logging
@@ -141,7 +142,8 @@ async def dbapi_query_result(request: Request, uid: str):
if not path.is_relative_to(DBAPI_DIR.resolve()) or not path.is_file(): if not path.is_relative_to(DBAPI_DIR.resolve()) or not path.is_file():
return error(404, "Result not available") return error(404, "Result not available")
queue.touch_job(uid, get_int_setting("dbquery_retention_seconds", 604800)) queue.touch_job(uid, get_int_setting("dbquery_retention_seconds", 604800))
return JSONResponse(json.loads(path.read_text(encoding="utf-8"))) text = await asyncio.to_thread(path.read_text, encoding="utf-8")
return JSONResponse(json.loads(text))
@router.websocket("/query/{uid}/ws") @router.websocket("/query/{uid}/ws")
@@ -131,7 +131,7 @@ async def instance_detail(request: Request, project_slug: str, uid: str):
"events": store.list_events(uid), "events": store.list_events(uid),
"schedules": store.list_schedules(uid), "schedules": store.list_schedules(uid),
"stats": api.instance_stats(uid), "stats": api.instance_stats(uid),
"runtime": api.instance_runtime(inst), "runtime": await api.instance_runtime(inst),
} }
) )
@@ -74,7 +74,7 @@ async def workspace_page(request: Request, slug: str):
profile = editor.resolve(user["uid"], instance) profile = editor.resolve(user["uid"], instance)
context = { context = {
"project": project, "project": project,
"workspace": provision.view(instance) if instance else None, "workspace": await provision.view(instance) if instance else None,
"has_workspace": bool(instance), "has_workspace": bool(instance),
"viewer_can_workspace": True, "viewer_can_workspace": True,
"workspace_count": provision.count_for_owner(user["uid"]), "workspace_count": provision.count_for_owner(user["uid"]),
@@ -130,7 +130,7 @@ async def workspace_open(request: Request, slug: str):
) )
provision.write_manifest(instance) provision.write_manifest(instance)
return action_result( return action_result(
request, f"/projects/{slug}/workspace", data=provision.view(instance) request, f"/projects/{slug}/workspace", data=await provision.view(instance)
) )
+3 -3
View File
@@ -187,12 +187,12 @@ async def projects_page(
model=ProjectsOut, model=ProjectsOut,
) )
def _editor_launch(project: dict, user: dict) -> dict: async def _editor_launch(project: dict, user: dict) -> dict:
from devplacepy.services.containers.workspace import editor, provision from devplacepy.services.containers.workspace import editor, provision
blank = {"url": "", "mode": "tab", "width": 0, "height": 0} blank = {"url": "", "mode": "tab", "width": 0, "height": 0}
instance = provision.find_for_project(project["uid"], user["uid"]) instance = provision.find_for_project(project["uid"], user["uid"])
if not instance or not provision.editor_ready(instance): if not instance or not await provision.editor_ready(instance):
return blank return blank
slug = project["slug"] or project["uid"] slug = project["slug"] or project["uid"]
profile = editor.resolve(user["uid"], instance) profile = editor.resolve(user["uid"], instance)
@@ -262,7 +262,7 @@ async def project_detail(request: Request, project_slug: str, before: str = None
) )
viewer_can_workspace = can_open_workspace(project, user) viewer_can_workspace = can_open_workspace(project, user)
editor_launch = ( editor_launch = (
_editor_launch(project, user) await _editor_launch(project, user)
if viewer_can_workspace if viewer_can_workspace
else {"url": "", "mode": "tab", "width": 0, "height": 0} else {"url": "", "mode": "tab", "width": 0, "height": 0}
) )
+2 -1
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import asyncio
import logging import logging
import re import re
from urllib.parse import quote from urllib.parse import quote
@@ -435,7 +436,7 @@ async def isslop_source(request: Request, uid: str, path: str, line: int = 0):
source_path = (store.media_dir_for(uid) / source_name).resolve() source_path = (store.media_dir_for(uid) / source_name).resolve()
if not source_path.is_relative_to(ISSLOP_MEDIA_DIR.resolve()) or not source_path.is_file(): if not source_path.is_relative_to(ISSLOP_MEDIA_DIR.resolve()) or not source_path.is_file():
raise not_found("Source not available for this file") raise not_found("Source not available for this file")
text = source_path.read_text(encoding="utf-8", errors="replace") text = await asyncio.to_thread(source_path.read_text, encoding="utf-8", errors="replace")
signals = store.decode_json(result.get("signals"), []) signals = store.decode_json(result.get("signals"), [])
marked: dict[int, list] = {} marked: dict[int, list] = {}
for signal in signals: for signal in signals:
+19 -12
View File
@@ -674,26 +674,33 @@ def instance_stats(instance_uid: str) -> dict:
} }
def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool: async def _port_reachable(host: str, port: int, timeout: float = 0.3) -> bool:
try: try:
with socket.create_connection((host, port), timeout=timeout): _, writer = await asyncio.wait_for(
return True asyncio.open_connection(host, port), timeout=timeout
except OSError: )
except (OSError, asyncio.TimeoutError):
return False return False
writer.close()
try:
await writer.wait_closed()
except OSError:
pass
return True
def editor_reachable(instance: dict) -> bool: async def editor_reachable(instance: dict) -> bool:
port = int(instance.get("editor_port") or 0) port = int(instance.get("editor_port") or 0)
host, target_port = tunnel_target(instance, port) host, target_port = tunnel_target(instance, port)
if not host or not target_port: if not host or not target_port:
return False return False
return _port_reachable(host, target_port) return await _port_reachable(host, target_port)
def _http_probe(host: str, port: int, timeout: float = 1.0) -> str: async def _http_probe(host: str, port: int, timeout: float = 1.0) -> str:
try: try:
with stealth.stealth_sync_client(timeout=timeout) as client: async with stealth.stealth_async_client(timeout=timeout) as client:
response = client.get(f"http://{host}:{port}/") response = await client.get(f"http://{host}:{port}/")
return f"HTTP {response.status_code}" return f"HTTP {response.status_code}"
except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative except Exception as exc: # noqa: BLE001 - diagnostic, any failure is informative
return f"unreachable: {type(exc).__name__}" return f"unreachable: {type(exc).__name__}"
@@ -760,7 +767,7 @@ def tunnel_target(instance: dict, container_port: int) -> tuple:
return reachable_target(instance, container_port, port_maps) return reachable_target(instance, container_port, port_maps)
def instance_runtime(instance: dict) -> dict: async def instance_runtime(instance: dict) -> dict:
boot = (instance.get("boot_command") or "").strip() boot = (instance.get("boot_command") or "").strip()
port_maps = json.loads(instance.get("ports_json") or "[]") port_maps = json.loads(instance.get("ports_json") or "[]")
container_ip = (instance.get("container_ip") or "").strip() container_ip = (instance.get("container_ip") or "").strip()
@@ -775,13 +782,13 @@ def instance_runtime(instance: dict) -> dict:
"container": int(mapping.get("container") or 0), "container": int(mapping.get("container") or 0),
"host": host_port, "host": host_port,
"proto": mapping.get("proto", "tcp"), "proto": mapping.get("proto", "tcp"),
"reachable": _port_reachable(probe_host, host_port) "reachable": await _port_reachable(probe_host, host_port)
if host_port if host_port
else False, else False,
} }
) )
ingress_serving = ( ingress_serving = (
_http_probe(target_host, target_port) await _http_probe(target_host, target_port)
if target_host and target_port if target_host and target_port
else "no ingress port mapped" else "no ingress port mapped"
) )
@@ -241,12 +241,12 @@ def write_manifest(instance: dict) -> None:
return return
def editor_ready(instance: dict) -> bool: async def editor_ready(instance: dict) -> bool:
if instance.get("suspended_at"): if instance.get("suspended_at"):
return False return False
if instance.get("status") != store.ST_RUNNING: if instance.get("status") != store.ST_RUNNING:
return False return False
return api.editor_reachable(instance) return await api.editor_reachable(instance)
def phase(instance: dict, ready: bool) -> str: def phase(instance: dict, ready: bool) -> str:
@@ -264,12 +264,12 @@ def phase(instance: dict, ready: bool) -> str:
return PHASE_STOPPED return PHASE_STOPPED
def view(instance: dict) -> dict: async def view(instance: dict) -> dict:
owner_uid = instance.get("workspace_owner_uid", "") owner_uid = instance.get("workspace_owner_uid", "")
limits = quota.resolve(owner_uid, instance) limits = quota.resolve(owner_uid, instance)
disk_used = int(instance.get("disk_bytes") or 0) disk_used = int(instance.get("disk_bytes") or 0)
egress_used = int(instance.get("egress_bytes") or 0) egress_used = int(instance.get("egress_bytes") or 0)
ready = editor_ready(instance) ready = await editor_ready(instance)
current = phase(instance, ready) current = phase(instance, ready)
return { return {
"uid": instance.get("uid", ""), "uid": instance.get("uid", ""),
+5 -3
View File
@@ -88,7 +88,7 @@ class DbApiJobService(JobService):
rows, truncated = await self._stream(uid, verdict.sql, max_rows) rows, truncated = await self._stream(uid, verdict.sql, max_rows)
output_dir = self.result_dir(uid) output_dir = self.result_dir(uid)
output_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
result = { result = {
"sql": verdict.sql, "sql": verdict.sql,
"row_count": len(rows), "row_count": len(rows),
@@ -96,8 +96,10 @@ class DbApiJobService(JobService):
"suspicious": verdict.suspicious, "suspicious": verdict.suspicious,
"rows": rows, "rows": rows,
} }
(output_dir / "result.json").write_text( await asyncio.to_thread(
json.dumps(result, default=str), encoding="utf-8" (output_dir / "result.json").write_text,
json.dumps(result, default=str),
encoding="utf-8",
) )
hub.publish( hub.publish(
uid, uid,
@@ -225,7 +225,7 @@ class ContainerController:
"instance": inst["name"], "instance": inst["name"],
"status": inst["status"], "status": inst["status"],
"ingress_url": self._ingress_url(inst), "ingress_url": self._ingress_url(inst),
"runtime": api.instance_runtime(inst), "runtime": await api.instance_runtime(inst),
"stats": api.instance_stats(inst["uid"]), "stats": api.instance_stats(inst["uid"]),
}, },
default=str, default=str,
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
from typing import Any from typing import Any
@@ -73,23 +74,23 @@ class WorkspaceController:
instance = await provision.ensure(project, user) instance = await provision.ensure(project, user)
instance = provision.resume(instance) instance = provision.resume(instance)
provision.write_manifest(instance) provision.write_manifest(instance)
view = provision.view(instance) view = await provision.view(instance)
view["editor_url"] = ( view["editor_url"] = (
f"/projects/{project.get('slug') or project['uid']}/workspace" f"/projects/{project.get('slug') or project['uid']}/workspace"
) )
return view return view
def _workspace_status(self, args: dict) -> Any: async def _workspace_status(self, args: dict) -> Any:
return provision.view(self._resolve(args.get("project_slug", ""))) return await provision.view(self._resolve(args.get("project_slug", "")))
def _workspace_list(self, args: dict) -> Any: async def _workspace_list(self, args: dict) -> Any:
table = get_table("instances") table = get_table("instances")
filters: dict[str, Any] = {"is_workspace": 1, "deleted_at": None} filters: dict[str, Any] = {"is_workspace": 1, "deleted_at": None}
if not self.admin: if not self.admin:
filters["workspace_owner_uid"] = self.owner_id filters["workspace_owner_uid"] = self.owner_id
return { rows = list(table.find(**filters))
"workspaces": [provision.view(row) for row in table.find(**filters)] views = await asyncio.gather(*(provision.view(row) for row in rows))
} return {"workspaces": list(views)}
def _workspace_stop(self, args: dict) -> Any: def _workspace_stop(self, args: dict) -> Any:
instance = self._resolve(args.get("project_slug", "")) instance = self._resolve(args.get("project_slug", ""))
+3 -3
View File
@@ -86,7 +86,7 @@ class JobService(BaseService):
self._reap() self._reap()
self._recover_orphans(min_age_seconds=self.job_timeout_seconds()) self._recover_orphans(min_age_seconds=self.job_timeout_seconds())
self._refill() self._refill()
self._sweep_expired() await self._sweep_expired()
def _reap(self) -> None: def _reap(self) -> None:
for uid in list(self._inflight): for uid in list(self._inflight):
@@ -208,7 +208,7 @@ class JobService(BaseService):
) )
self.log(f"Recovered orphaned job {uid} (retry {retry_count + 1})") self.log(f"Recovered orphaned job {uid} (retry {retry_count + 1})")
def _sweep_expired(self) -> None: async def _sweep_expired(self) -> None:
table = get_table("jobs") table = get_table("jobs")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
for row in table.find(kind=self.kind, status=queue.DONE): for row in table.find(kind=self.kind, status=queue.DONE):
@@ -217,7 +217,7 @@ class JobService(BaseService):
continue continue
job = queue.get_job(row["uid"]) job = queue.get_job(row["uid"])
try: try:
self.cleanup(job) await asyncio.to_thread(self.cleanup, job)
except Exception as exc: except Exception as exc:
self.log(f"Cleanup failed for {row['uid']}: {exc}") self.log(f"Cleanup failed for {row['uid']}: {exc}")
table.delete(uid=row["uid"]) table.delete(uid=row["uid"])
+15 -11
View File
@@ -59,15 +59,17 @@ class DeepsearchService(JobService):
payload = dict(job.get("payload", {})) payload = dict(job.get("payload", {}))
query = payload.get("query", "") query = payload.get("query", "")
output_dir = self.session_dir(uid) output_dir = self.session_dir(uid)
output_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
(output_dir / "control.json").write_text( await asyncio.to_thread(
json.dumps({"state": "running"}), encoding="utf-8" (output_dir / "control.json").write_text,
json.dumps({"state": "running"}),
encoding="utf-8",
) )
payload["collection"] = self.collection_name(uid) payload["collection"] = self.collection_name(uid)
payload["model"] = self.get_config()["deepsearch_model"] or INTERNAL_MODEL payload["model"] = self.get_config()["deepsearch_model"] or INTERNAL_MODEL
payload["cached_hashes"] = self._cached_hashes(database) payload["cached_hashes"] = self._cached_hashes(database)
payload_path = output_dir / "payload.json" payload_path = output_dir / "payload.json"
payload_path.write_text(json.dumps(payload), encoding="utf-8") await asyncio.to_thread(payload_path.write_text, json.dumps(payload), encoding="utf-8")
actor_kind = job.get("owner_kind") or "system" actor_kind = job.get("owner_kind") or "system"
actor_uid = job.get("owner_id") if job.get("owner_kind") == "user" else None actor_uid = job.get("owner_id") if job.get("owner_kind") == "user" else None
@@ -89,11 +91,11 @@ class DeepsearchService(JobService):
metadata={"query": query, "error": str(exc)[:200]}, metadata={"query": query, "error": str(exc)[:200]},
links=[audit.job(uid)], links=[audit.job(uid)],
) )
shutil.rmtree(output_dir, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, output_dir, ignore_errors=True)
raise raise
report = self._load_report(output_dir) report = await self._load_report(output_dir)
self._persist_cache(database, output_dir) await self._persist_cache(database, output_dir)
from datetime import datetime, timezone from datetime import datetime, timezone
database.update_deepsearch_session( database.update_deepsearch_session(
@@ -158,12 +160,13 @@ class DeepsearchService(JobService):
if row.get("url_hash") if row.get("url_hash")
] ]
def _persist_cache(self, database, output_dir: Path) -> None: async def _persist_cache(self, database, output_dir: Path) -> None:
path = output_dir / "url_cache.json" path = output_dir / "url_cache.json"
if not path.is_file(): if not path.is_file():
return return
try: try:
entries = json.loads(path.read_text(encoding="utf-8")) text = await asyncio.to_thread(path.read_text, encoding="utf-8")
entries = json.loads(text)
except (ValueError, OSError): except (ValueError, OSError):
return return
for entry in entries: for entry in entries:
@@ -244,12 +247,13 @@ class DeepsearchService(JobService):
) )
return summary return summary
def _load_report(self, output_dir: Path) -> dict: async def _load_report(self, output_dir: Path) -> dict:
report_path = output_dir / "report.json" report_path = output_dir / "report.json"
if not report_path.is_file(): if not report_path.is_file():
return {} return {}
try: try:
return json.loads(report_path.read_text(encoding="utf-8")) text = await asyncio.to_thread(report_path.read_text, encoding="utf-8")
return json.loads(text)
except (ValueError, OSError): except (ValueError, OSError):
return {} return {}
+6 -4
View File
@@ -141,21 +141,23 @@ class IsslopService(JobService):
) )
run_dir = self.run_dir(uid) run_dir = self.run_dir(uid)
run_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(run_dir.mkdir, parents=True, exist_ok=True)
payload_path = run_dir / "payload.json" payload_path = run_dir / "payload.json"
payload_path.write_text(json.dumps(self._worker_payload(job)), encoding="utf-8") await asyncio.to_thread(
payload_path.write_text, json.dumps(self._worker_payload(job)), encoding="utf-8"
)
try: try:
workspace = workspace_for(ISSLOP_WORKSPACES_DIR, source_url, uid) workspace = workspace_for(ISSLOP_WORKSPACES_DIR, source_url, uid)
except ValueError as error: except ValueError as error:
await self._relay(persister, WorkerEvent(KIND_ERROR, f"Workspace rejected: {error}", {})) await self._relay(persister, WorkerEvent(KIND_ERROR, f"Workspace rejected: {error}", {}))
shutil.rmtree(run_dir, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, run_dir, ignore_errors=True)
raise RuntimeError(str(error)) raise RuntimeError(str(error))
try: try:
final = await self._run_worker(uid, persister, payload_path, workspace) final = await self._run_worker(uid, persister, payload_path, workspace)
finally: finally:
await asyncio.to_thread(remove_workspace, workspace) await asyncio.to_thread(remove_workspace, workspace)
shutil.rmtree(run_dir, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, run_dir, ignore_errors=True)
if final.kind == KIND_ERROR: if final.kind == KIND_ERROR:
audit.record_system( audit.record_system(
+3 -2
View File
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import asyncio
import logging import logging
import zlib import zlib
from pathlib import Path from pathlib import Path
@@ -52,9 +53,9 @@ class PlanningReportService(JobService):
final_name = self._final_name(job.get("preferred_name", ""), markdown) final_name = self._final_name(job.get("preferred_name", ""), markdown)
target_dir = PLANNING_REPORTS_DIR / _directory_for(uid) target_dir = PLANNING_REPORTS_DIR / _directory_for(uid)
target_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(target_dir.mkdir, parents=True, exist_ok=True)
final_path = target_dir / final_name final_path = target_dir / final_name
final_path.write_text(markdown, encoding="utf-8") await asyncio.to_thread(final_path.write_text, markdown, encoding="utf-8")
bytes_out = len(markdown.encode("utf-8")) bytes_out = len(markdown.encode("utf-8"))
except Exception: except Exception:
audit.record_system( audit.record_system(
+7 -6
View File
@@ -41,9 +41,9 @@ class SeoService(JobService):
payload = job.get("payload", {}) payload = job.get("payload", {})
target = payload.get("url", "") target = payload.get("url", "")
output_dir = self.report_dir(uid) output_dir = self.report_dir(uid)
output_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
payload_path = output_dir / "payload.json" payload_path = output_dir / "payload.json"
payload_path.write_text(json.dumps(payload), encoding="utf-8") await asyncio.to_thread(payload_path.write_text, json.dumps(payload), encoding="utf-8")
actor_kind = "user" if job.get("owner_kind") == "user" else ( actor_kind = "user" if job.get("owner_kind") == "user" else (
job.get("owner_kind") or "system" job.get("owner_kind") or "system"
@@ -63,10 +63,10 @@ class SeoService(JobService):
metadata={"target": target, "error": str(exc)[:200]}, metadata={"target": target, "error": str(exc)[:200]},
links=[audit.job(uid)], links=[audit.job(uid)],
) )
shutil.rmtree(output_dir, ignore_errors=True) await asyncio.to_thread(shutil.rmtree, output_dir, ignore_errors=True)
raise raise
report = self._load_report(output_dir) report = await self._load_report(output_dir)
hub.publish( hub.publish(
uid, uid,
{ {
@@ -154,12 +154,13 @@ class SeoService(JobService):
) )
return summary return summary
def _load_report(self, output_dir: Path) -> dict: async def _load_report(self, output_dir: Path) -> dict:
report_path = output_dir / "report.json" report_path = output_dir / "report.json"
if not report_path.is_file(): if not report_path.is_file():
return {} return {}
try: try:
return json.loads(report_path.read_text(encoding="utf-8")) text = await asyncio.to_thread(report_path.read_text, encoding="utf-8")
return json.loads(text)
except (ValueError, OSError): except (ValueError, OSError):
return {} return {}
+2 -2
View File
@@ -41,12 +41,12 @@ class ZipService(JobService):
try: try:
item_count = await asyncio.to_thread(self._materialize, source, staging) item_count = await asyncio.to_thread(self._materialize, source, staging)
tmp_dir = ZIPS_DIR / _directory_for(uid) tmp_dir = ZIPS_DIR / _directory_for(uid)
tmp_dir.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(tmp_dir.mkdir, parents=True, exist_ok=True)
tmp_zip = tmp_dir / f"{generate_uid()}.zip" tmp_zip = tmp_dir / f"{generate_uid()}.zip"
stats = await self._run_worker(staging, tmp_zip) stats = await self._run_worker(staging, tmp_zip)
final_name = self._final_name(job.get("preferred_name", ""), stats["crc32"]) final_name = self._final_name(job.get("preferred_name", ""), stats["crc32"])
final_path = tmp_dir / final_name final_path = tmp_dir / final_name
tmp_zip.replace(final_path) await asyncio.to_thread(tmp_zip.replace, final_path)
except Exception: except Exception:
audit.record_system( audit.record_system(
"job.zip.failed", "job.zip.failed",
+2 -2
View File
@@ -58,7 +58,7 @@ async def _container_detail(match: re.Match) -> Optional[dict]:
"events": store.list_events(uid), "events": store.list_events(uid),
"schedules": store.list_schedules(uid), "schedules": store.list_schedules(uid),
"stats": api.instance_stats(uid), "stats": api.instance_stats(uid),
"runtime": api.instance_runtime(inst), "runtime": await api.instance_runtime(inst),
} }
@@ -128,7 +128,7 @@ async def _workspace_detail(match: re.Match) -> Optional[dict]:
project = _instance_project(inst) project = _instance_project(inst)
slug = project.get("slug") or project.get("uid") or "" slug = project.get("slug") or project.get("uid") or ""
return { return {
"workspace": provision.view(inst), "workspace": await provision.view(inst),
"editor_url": ( "editor_url": (
f"/projects/{slug}/containers/instances/{inst['uid']}/code/" if slug else "" f"/projects/{slug}/containers/instances/{inst['uid']}/code/" if slug else ""
), ),
+21 -19
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import subprocess import asyncio
import sys import sys
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
@@ -24,48 +24,50 @@ class XmlrpcService(BaseService):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__("xmlrpc", interval_seconds=XMLRPC_INTERVAL_SECONDS) super().__init__("xmlrpc", interval_seconds=XMLRPC_INTERVAL_SECONDS)
self._process: subprocess.Popen | None = None self._process: asyncio.subprocess.Process | None = None
def _alive(self) -> bool: def _alive(self) -> bool:
return self._process is not None and self._process.poll() is None return self._process is not None and self._process.returncode is None
def _spawn(self) -> None: async def _spawn(self) -> None:
self._process = subprocess.Popen( self._process = await asyncio.create_subprocess_exec(
[sys.executable, "-m", SERVER_MODULE], sys.executable,
stdout=subprocess.DEVNULL, "-m",
stderr=subprocess.DEVNULL, SERVER_MODULE,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
) )
self.log( self.log(
f"Forking XML-RPC server started (pid {self._process.pid}) on " f"Forking XML-RPC server started (pid {self._process.pid}) on "
f"{XMLRPC_BIND}:{XMLRPC_PORT}" f"{XMLRPC_BIND}:{XMLRPC_PORT}"
) )
def _terminate(self) -> None: async def _terminate(self) -> None:
if not self._alive(): process = self._process
self._process = None self._process = None
if process is None or process.returncode is not None:
return return
self._process.terminate() process.terminate()
try: try:
self._process.wait(timeout=TERMINATE_TIMEOUT_SECONDS) await asyncio.wait_for(process.wait(), timeout=TERMINATE_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired: except asyncio.TimeoutError:
self._process.kill() process.kill()
self._process.wait() await process.wait()
self.log("Forking XML-RPC server stopped") self.log("Forking XML-RPC server stopped")
self._process = None
async def on_enable(self) -> None: async def on_enable(self) -> None:
if not self._alive(): if not self._alive():
self._spawn() await self._spawn()
async def on_disable(self) -> None: async def on_disable(self) -> None:
self._terminate() await self._terminate()
async def run_once(self) -> None: async def run_once(self) -> None:
if self._alive(): if self._alive():
self.log(f"XML-RPC server healthy (pid {self._process.pid})") self.log(f"XML-RPC server healthy (pid {self._process.pid})")
return return
self.log("XML-RPC server not running, starting it") self.log("XML-RPC server not running, starting it")
self._spawn() await self._spawn()
def collect_metrics(self) -> dict: def collect_metrics(self) -> dict:
return { return {
+6 -3
View File
@@ -494,7 +494,7 @@ class ChromeStealthClient:
dest_type: str = "empty", dest_type: str = "empty",
) -> DownloadResult: ) -> DownloadResult:
directory = Path(destination) directory = Path(destination)
directory.mkdir(parents=True, exist_ok=True) await asyncio.to_thread(directory.mkdir, parents=True, exist_ok=True)
try: try:
target = resolve_destination(directory, url, filename) target = resolve_destination(directory, url, filename)
except ValueError as error: except ValueError as error:
@@ -505,10 +505,13 @@ class ChromeStealthClient:
try: try:
async with self.stream("GET", url, referer=referer, dest=dest_type) as response: async with self.stream("GET", url, referer=referer, dest=dest_type) as response:
response.raise_for_status() response.raise_for_status()
with target.open("wb") as handle: handle = await asyncio.to_thread(target.open, "wb")
try:
async for chunk in response.aiter_bytes(chunk_size): async for chunk in response.aiter_bytes(chunk_size):
handle.write(chunk) await asyncio.to_thread(handle.write, chunk)
written += len(chunk) written += len(chunk)
finally:
await asyncio.to_thread(handle.close)
logger.info("Downloaded %s -> %s (%d bytes)", url, target, written) logger.info("Downloaded %s -> %s (%d bytes)", url, target, written)
return DownloadResult( return DownloadResult(
url=url, url=url,
@@ -1,5 +1,6 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import asyncio
import itertools import itertools
import socket import socket
@@ -117,7 +118,7 @@ def test_transitional_phases_are_exactly_starting_and_stopping():
def test_editor_ready_when_the_editor_port_accepts_connections(listener): def test_editor_ready_when_the_editor_port_accepts_connections(listener):
port = listener.getsockname()[1] port = listener.getsockname()[1]
row = _instance(container_ip="127.0.0.1", editor_port=port) row = _instance(container_ip="127.0.0.1", editor_port=port)
assert provision.editor_ready(row) is True assert asyncio.run(provision.editor_ready(row)) is True
def test_editor_ready_is_false_when_nothing_listens(): def test_editor_ready_is_false_when_nothing_listens():
@@ -126,12 +127,12 @@ def test_editor_ready_is_false_when_nothing_listens():
port = probe.getsockname()[1] port = probe.getsockname()[1]
probe.close() probe.close()
row = _instance(container_ip="127.0.0.1", editor_port=port) row = _instance(container_ip="127.0.0.1", editor_port=port)
assert provision.editor_ready(row) is False assert asyncio.run(provision.editor_ready(row)) is False
def test_editor_ready_is_false_without_a_reachable_target(listener): def test_editor_ready_is_false_without_a_reachable_target(listener):
row = _instance(container_ip="", ports_json="[]") row = _instance(container_ip="", ports_json="[]")
assert provision.editor_ready(row) is False assert asyncio.run(provision.editor_ready(row)) is False
def test_editor_ready_is_false_unless_the_container_runs(listener): def test_editor_ready_is_false_unless_the_container_runs(listener):
@@ -140,7 +141,7 @@ def test_editor_ready_is_false_unless_the_container_runs(listener):
if status == store.ST_RUNNING: if status == store.ST_RUNNING:
continue continue
row = _instance(container_ip="127.0.0.1", editor_port=port, status=status) row = _instance(container_ip="127.0.0.1", editor_port=port, status=status)
assert provision.editor_ready(row) is False assert asyncio.run(provision.editor_ready(row)) is False
def test_editor_ready_is_false_while_suspended(listener): def test_editor_ready_is_false_while_suspended(listener):
@@ -148,7 +149,7 @@ def test_editor_ready_is_false_while_suspended(listener):
row = _instance( row = _instance(
container_ip="127.0.0.1", editor_port=port, suspended_at="2026-01-01T00:00:00" container_ip="127.0.0.1", editor_port=port, suspended_at="2026-01-01T00:00:00"
) )
assert provision.editor_ready(row) is False assert asyncio.run(provision.editor_ready(row)) is False
def test_view_carries_the_phase_its_label_readiness_and_the_owner(listener): def test_view_carries_the_phase_its_label_readiness_and_the_owner(listener):
@@ -166,13 +167,13 @@ def test_view_carries_the_phase_its_label_readiness_and_the_owner(listener):
"ports_json": "[]", "ports_json": "[]",
} }
) )
view = provision.view(instance) view = asyncio.run(provision.view(instance))
assert view["phase"] == provision.PHASE_READY assert view["phase"] == provision.PHASE_READY
assert view["phase_label"] == "Ready" assert view["phase_label"] == "Ready"
assert view["editor_ready"] is True assert view["editor_ready"] is True
assert view["owner_uid"] == OWNER assert view["owner_uid"] == OWNER
store.update_instance(instance["uid"], {"desired_state": store.DESIRED_STOPPED}) store.update_instance(instance["uid"], {"desired_state": store.DESIRED_STOPPED})
view = provision.view(store.get_instance(instance["uid"])) view = asyncio.run(provision.view(store.get_instance(instance["uid"])))
assert view["phase"] == provision.PHASE_STOPPING assert view["phase"] == provision.PHASE_STOPPING
assert view["editor_ready"] is True assert view["editor_ready"] is True