Skip to content

Instantly share code, notes, and snippets.

@aont
Last active November 15, 2025 02:24
Show Gist options
  • Select an option

  • Save aont/37aa5ea37c2a5fdeec041339fe30cf0d to your computer and use it in GitHub Desktop.

Select an option

Save aont/37aa5ea37c2a5fdeec041339fe30cf0d to your computer and use it in GitHub Desktop.

Async Plugin Runner

1. Start the server

python server.py

Server listens on 127.0.0.1:9000 and accepts line-delimited JSON.


2. Run a plugin

echo '{"action": "run", "filepath": "./sample.py", "entry": "main", "args": [{"name": "alice", "count": 2}]}' | nc 127.0.0.1 9000
  • action: "run"
  • filepath: plugin file (e.g. ./sample.py)
  • entry: async function name (default: "main")
  • args: positional args (here, one dict passed to main)

Response example:

{"status": "ok", "task_id": "...", "filepath": "/abs/path/sample.py"}

3. Stop plugin tasks

Stop all tasks from a file

echo '{"action": "stop", "filepath": "./sample.py"}' | nc 127.0.0.1 9000

Response example:

{"status": "ok", "filepath": "/abs/path/sample.py", "stopped": 1}

Stop a specific task by ID

echo '{"action": "stop", "task_id": "YOUR_TASK_ID_HERE"}' | nc 127.0.0.1 9000

4. Check current state

echo '{"action": "state"}' | nc 127.0.0.1 9000

Shows current tasks and their status.

# plugins/sample_plugin.py
import asyncio
from typing import Any, Dict
import os
async def main(payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Required interface: async def main(dict) -> Any
"""
global data
data = os.urandom(1024) * 1024 * 4
name = payload.get("name", "world")
count = int(payload.get("count", 3))
for i in range(count):
print(f"[sample_plugin] hello {name} ({i + 1}/{count})")
await asyncio.sleep(1.0)
return {"message": f"finished for {name}", "count": count}
#!/usr/bin/env python
import asyncio
import importlib.util
import json
import traceback
import uuid
import gc
from pathlib import Path
from typing import Any, Dict, Optional
class PluginManager:
"""
Manager for dynamically loading .py files and running async entrypoints.
Characteristics:
- Does NOT use sys.modules at all.
- No dedicated "load" action (every run loads the file fresh).
- The module is held strongly only within run_entrypoint until the task is created.
- Each run loads a new module instance even for the same filepath.
- No global module registry; only running tasks are tracked.
- Task completion is handled via add_done_callback, and gc.collect() is
invoked in the finalizer.
- The "stop" action cancels running tasks (equivalent to cancel_running=true).
"""
def __init__(self) -> None:
# Only track running tasks
self.tasks: Dict[str, asyncio.Task] = {}
# ------------------------------------------------------------------
# Load module from filepath (without touching sys.modules)
# ------------------------------------------------------------------
def _load_module_from_path(self, filepath: str):
"""
Load a Python module object from a .py filepath.
A new module object is created every time this is called.
"""
path = str(Path(filepath).resolve())
# Generate a unique module name (but do NOT register it in sys.modules)
module_name = f"plugin_{uuid.uuid4().hex}"
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot create module spec for {path}")
module = importlib.util.module_from_spec(spec)
# type: ignore[arg-type]
spec.loader.exec_module(module)
# We do not register this in sys.modules; just set the name on the object
module.__name__ = module_name
return module, path
# ------------------------------------------------------------------
# Run entrypoint (always fire-and-forget)
# ------------------------------------------------------------------
async def run_entrypoint(
self,
*,
filepath: str,
entry_name: str = "main",
args: Optional[list] = None,
kwargs: Optional[dict] = None,
) -> Dict[str, Any]:
"""
Load the .py file at `filepath`, find the async entry function `entry_name`,
and start it as an asyncio.Task.
Behavior:
- The module object is strongly referenced only within this method.
- After the task is created, local references go out of scope.
- The task itself holds the coroutine/function/module references.
- The return value of the entrypoint is ignored.
- A task_id is returned as soon as the task is created.
"""
if args is None:
args = []
if kwargs is None:
kwargs = {}
# Load module and keep strong reference while creating the task
module, real_path = self._load_module_from_path(filepath)
# Get entrypoint
entry = getattr(module, entry_name, None)
if entry is None:
raise RuntimeError(f"{entry_name} not found in {real_path}")
if not asyncio.iscoroutinefunction(entry):
raise RuntimeError(f"{entry_name} is not an async function ({real_path})")
# Create coroutine
coro = entry(*args, **kwargs)
# Create task – this establishes references to coroutine/function/module
task = asyncio.create_task(coro)
task_id = str(uuid.uuid4())
setattr(task, "_plugin_filepath", real_path)
self.tasks[task_id] = task
# Finalizer: cleanup + gc.collect, implemented via add_done_callback
def _finalizer(t: asyncio.Task, tid: str = task_id, manager: "PluginManager" = self) -> None:
try:
# This will re-raise any exception if the task failed
_ = t.exception()
except asyncio.CancelledError:
# Normal case when task is cancelled
pass
except Exception:
print(f"[task {tid}] exception:")
traceback.print_exc()
finally:
# Remove from task registry
manager.tasks.pop(tid, None)
# Force a garbage collection cycle
gc.collect()
# We intentionally ignore the return value.
task.add_done_callback(_finalizer)
# At this point, `module` goes out of scope when this method returns.
# Strong references are now only held by the running task.
return {"task_id": task_id, "filepath": real_path}
# ------------------------------------------------------------------
# Task cancellation (for "stop" action)
# ------------------------------------------------------------------
async def stop_by_filepath(self, filepath: str) -> int:
"""
Cancel all tasks associated with the given filepath.
We only manage tasks here, not modules.
"""
path = str(Path(filepath).resolve())
targets = [
(tid, task)
for tid, task in list(self.tasks.items())
if getattr(task, "_plugin_filepath", None) == path
]
for tid, task in targets:
task.cancel()
for tid, task in targets:
try:
await task
except asyncio.CancelledError:
pass
except Exception:
traceback.print_exc()
finally:
self.tasks.pop(tid, None)
# GC is also triggered by the finalizer, but this is harmless.
return len(targets)
async def stop_task(self, task_id: str) -> bool:
"""
Cancel a single task by task_id.
"""
task = self.tasks.get(task_id)
if task is None:
return False
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
except Exception:
traceback.print_exc()
finally:
self.tasks.pop(task_id, None)
# GC is also triggered by the finalizer, but this is harmless.
return True
# ------------------------------------------------------------------
# State inspection
# ------------------------------------------------------------------
def list_state(self) -> Dict[str, Any]:
"""
Return a simple snapshot of current tasks.
"""
return {
"tasks": {
task_id: {
"done": task.done(),
"cancelled": task.cancelled(),
"filepath": getattr(task, "_plugin_filepath", None),
}
for task_id, task in self.tasks.items()
},
}
manager = PluginManager()
# ----------------------------------------------------------------------
# TCP client handler
# ----------------------------------------------------------------------
async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
"""
Handle a single TCP client connection.
Protocol: line-delimited JSON
- Each line must be one JSON object.
"""
addr = writer.get_extra_info("peername")
print(f"connection from {addr}")
try:
while True:
line = await reader.readline()
if not line:
break # client disconnected
try:
req = json.loads(line.decode("utf-8"))
except json.JSONDecodeError:
resp = {"status": "error", "error": "invalid json"}
writer.write((json.dumps(resp) + "\n").encode("utf-8"))
await writer.drain()
continue
action = req.get("action")
try:
if action == "run":
filepath = req["filepath"]
entry = req.get("entry", "main")
args = req.get("args", [])
kwargs = req.get("kwargs", {})
result = await manager.run_entrypoint(
filepath=filepath,
entry_name=entry,
args=args,
kwargs=kwargs,
)
# result: {"task_id": ..., "filepath": ...}
resp = {"status": "ok", **result}
elif action == "stop":
# stop = cancel running tasks (cancel_running=true semantics)
task_id = req.get("task_id")
filepath = req.get("filepath")
if task_id is not None:
ok = await manager.stop_task(task_id)
if ok:
resp = {"status": "ok", "task_id": task_id}
else:
resp = {"status": "error", "error": f"task not found: {task_id}"}
elif filepath is not None:
count = await manager.stop_by_filepath(filepath)
resp = {
"status": "ok",
"filepath": str(Path(filepath).resolve()),
"stopped": count,
}
else:
raise RuntimeError("stop requires either task_id or filepath")
elif action == "state":
resp = {"status": "ok", **manager.list_state()}
else:
resp = {"status": "error", "error": f"unknown action: {action}"}
except Exception as e:
traceback.print_exc()
resp = {"status": "error", "error": repr(e)}
writer.write((json.dumps(resp) + "\n").encode("utf-8"))
await writer.drain()
finally:
print(f"connection closed: {addr}")
writer.close()
await writer.wait_closed()
# ----------------------------------------------------------------------
# Main (event loop)
# ----------------------------------------------------------------------
async def main() -> None:
server = await asyncio.start_server(handle_client, host="127.0.0.1", port=9000)
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
print(f"Serving on {addrs}")
async with server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment