Compare commits

..
Author SHA1 Message Date
deepend 66c93fe2e1 expose main-loop file descriptor hooks
(cherry picked from commit 92d39cd077)
2026-11-08 06:04:48 -06:00
6 changed files with 81 additions and 130 deletions

No files matched your search

-30
View File
@@ -1,30 +0,0 @@
# Optional Python asyncio integration
`run_async(coroutine, context=None, interval=10)` returns an AsyncTask with
`done()`, `cancel()` and `result()`. Each invocation owns a separate event loop
pumped by an existing main-loop timer. A ready stop callback ensures selector
polling never blocks the UI. The captured context is restored after every pump;
a closed context cancels the work. No process-wide event-loop policy is changed.
Existing synchronous scripts are unaffected. Use `asyncio.get_running_loop()`
inside the coroutine. Child tasks use the same loop/context and are cancelled
when the main coroutine finishes or the script unloads. Cancellation cleanup is
bounded; tasks must not suppress cancellation indefinitely. Cleanup uses the
captured context while it remains open; after it closes, cancellation cleanup uses
the current valid context. Use explicit Context methods for context-specific
output in finally blocks, and do not issue context-dependent commands there. This provides normal
asyncio sockets/timers, not Windows subprocess support or a new HTTP library.
Asyncio hostname resolution can use its executor; use a resolver appropriate to
your library if you require no worker threads. Blocking work inside a coroutine
still blocks the client and must be avoided.
```python
import asyncio
import zoitechat
async def later():
await asyncio.sleep(1)
zoitechat.prnt('Still in the context where this task was started')
task = zoitechat.run_async(later())
```
+19
View File
@@ -0,0 +1,19 @@
# Python descriptor hooks
`hook_fd(fd, callback, userdata=None, flags=FD_READ)` returns an ordinary hook
handle accepted by `unhook()`. Callback arguments are `(fd, ready_flags, userdata)`;
return True to retain the watch and False/None to remove it. Exceptions print a
traceback and remove the watch. Unloading the script removes its watches.
The callback runs in the context captured during registration, and that context
is restored afterwards. A closed context removes the watch.
Use nonblocking IO and bounded reads/writes. The descriptor belongs to the script;
unhook before closing it. On Windows socket descriptors use the default flags;
CRT descriptors need FD_NOTSOCKET. The existing C API uses int descriptors: values
outside its range are rejected rather than truncated. This does not add threads.
Example:
```python
watch = zoitechat.hook_fd(sock.fileno(), on_readable, flags=zoitechat.FD_READ)
# def on_readable(fd, flags, userdata): ...; return True
```
-100
View File
@@ -1,100 +0,0 @@
"""Opt-in asyncio tasks pumped without blocking the client main loop."""
import asyncio
import operator
import traceback
import _zoitechat as api
__all__ = ['AsyncTask', 'run_async']
class AsyncTask:
"""A script-owned task and isolated event loop; cancel() is cooperative."""
def __init__(self, coroutine, plugin, context, interval):
self._plugin = plugin
self._context = context
self._loop = asyncio.new_event_loop()
self._task = self._loop.create_task(coroutine)
self._closed = False
self._unload_hook = api.hook_unload(lambda data: self._close())
self._timer_hook = api.hook_timer(interval, self._tick)
def done(self):
return self._task.done()
def cancel(self):
if not self._closed:
return self._task.cancel()
return False
def result(self):
return self._task.result()
def _pump(self):
# A ready stop callback forces the selector's timeout to zero.
self._loop.call_soon(self._loop.stop)
self._loop.run_forever()
def _close(self):
if self._closed:
return
self._closed = True
previous = api.get_context()
self._context.set()
try:
pending = asyncio.all_tasks(self._loop)
for task in pending:
task.cancel()
# Bound cleanup work; tasks must cooperate with cancellation.
for unused in range(3):
if not any(not task.done() for task in pending):
break
self._pump()
for task in pending:
if not task.done():
self._loop.call_exception_handler({
'message': 'Script task ignored cancellation during unload',
'task': task,
})
finally:
self._loop.close()
previous.set()
def _tick(self, userdata):
if self._closed:
return False
previous = api.get_context()
if not self._context.set():
self._close()
self._plugin.remove_hook(self._unload_hook)
return False
try:
self._pump()
if not self._task.done():
return True
try:
self._task.result()
except asyncio.CancelledError:
pass
except Exception:
traceback.print_exc()
self._close()
self._plugin.remove_hook(self._unload_hook)
return False
except Exception:
traceback.print_exc()
self._close()
self._plugin.remove_hook(self._unload_hook)
return False
finally:
previous.set()
def run_async(coroutine, context=None, interval=10):
"""Schedule a coroutine in a captured context; return an AsyncTask handle."""
interval = operator.index(interval)
if interval < 1 or interval > 2147483647:
raise ValueError('interval must be a positive C int in milliseconds')
if not asyncio.iscoroutine(coroutine):
raise TypeError('run_async expects a coroutine object')
plugin = api.__get_current_plugin()
return AsyncTask(coroutine, plugin, context or api.get_context(), interval)
+39
View File
@@ -0,0 +1,39 @@
"""File descriptor readiness hooks; callbacks execute on the main thread."""
import operator
import _zoitechat as api
from _zoitechat_embedded import ffi, lib
__all__ = ['FD_READ', 'FD_WRITE', 'FD_EXCEPTION', 'FD_NOTSOCKET', 'hook_fd']
FD_READ, FD_WRITE, FD_EXCEPTION, FD_NOTSOCKET = 1, 2, 4, 8
def hook_fd(fd, callback, userdata=None, flags=FD_READ):
"""callback(fd, ready_flags, userdata): True keeps the watch, False removes it."""
fd = operator.index(fd)
flags = operator.index(flags)
if fd < 0 or fd > 2147483647:
raise ValueError('fd must fit the C API nonnegative int descriptor')
if flags & ~15 or not flags & 7:
raise ValueError('flags must select READ, WRITE or EXCEPTION')
if not callable(callback):
raise TypeError('callback must be callable')
plugin = api.__get_current_plugin()
context = api.get_context()
def dispatch(descriptor, ready, data):
previous = api.get_context()
if not context.set():
return False
try:
return callback(descriptor, ready, data)
finally:
previous.set()
hook = plugin.add_hook(dispatch, userdata)
handle = lib.zoitechat_hook_fd(lib.ph, fd, flags, lib._on_fd_hook, hook.handle)
if handle == ffi.NULL:
hook.is_unload = True
plugin.remove_hook(id(hook))
raise RuntimeError('unable to watch descriptor')
hook.zoitechat_hook = handle
return id(hook)
+1
View File
@@ -38,6 +38,7 @@ extern "Python" int _on_print_attrs_hook(char **, zoitechat_event_attrs *, void
extern "Python" int _on_server_hook(char **, char **, void *); extern "Python" int _on_server_hook(char **, char **, void *);
extern "Python" int _on_server_attrs_hook(char **, char **, zoitechat_event_attrs *, void *); extern "Python" int _on_server_attrs_hook(char **, char **, zoitechat_event_attrs *, void *);
extern "Python" int _on_timer_hook(void *); extern "Python" int _on_timer_hook(void *);
extern "Python" int _on_fd_hook(int, int, void *);
extern "Python" int _on_plugin_init(char **, char **, char **, char *, char *); extern "Python" int _on_plugin_init(char **, char **, char **, char *, char *);
extern "Python" int _on_plugin_deinit(void); extern "Python" int _on_plugin_deinit(void);
+22
View File
@@ -304,6 +304,28 @@ def _on_timer_hook(userdata):
return 0 return 0
@ffi.def_extern()
def _on_fd_hook(fd, flags, userdata):
hook = ffi.from_handle(userdata)
keep = False
try:
keep = bool(hook.callback(fd, flags, hook.userdata))
except Exception:
traceback.print_exc()
if not keep:
# The C watch removes itself on return; do not unhook it a second time.
try:
hook.is_unload = True
# from_handle() yields a weak proxy: id(proxy) is not id(Hook).
for existing in hook.plugin.hooks:
if existing == hook:
hook.plugin.remove_hook(id(existing))
break
except ReferenceError:
pass # the callback already unhooked itself
return int(keep)
@ffi.def_extern() @ffi.def_extern()
def _on_say_command(word, word_eol, userdata): def _on_say_command(word, word_eol, userdata):
"""Handle input in the special >>python<< tab. """Handle input in the special >>python<< tab.