|
37 | 37 | "source": [ |
38 | 38 | "#| export\n", |
39 | 39 | "import os,re,inspect,ast,collections,time,asyncio,json,linecache,importlib,difflib,uuid,builtins,subprocess,sys\n", |
| 40 | + "import websockets\n", |
40 | 41 | "\n", |
41 | 42 | "from typing import Dict\n", |
42 | 43 | "from tempfile import TemporaryDirectory\n", |
|
933 | 934 | "await js_eval_a(\"return (await fetch('/curr_dialog_', {method:'POST'})).json()\")" |
934 | 935 | ] |
935 | 936 | }, |
| 937 | + { |
| 938 | + "cell_type": "markdown", |
| 939 | + "id": "fbfeec09", |
| 940 | + "metadata": {}, |
| 941 | + "source": [ |
| 942 | + "The helpers above make one-shot round trips through the browser page, and remain the right choice for collecting single values. `Channel` instead holds open a persistent two-way connection, for long-lived protocols and event streams where per-message HTTP overhead would hurt." |
| 943 | + ] |
| 944 | + }, |
| 945 | + { |
| 946 | + "cell_type": "code", |
| 947 | + "execution_count": null, |
| 948 | + "id": "84469f4e", |
| 949 | + "metadata": {}, |
| 950 | + "outputs": [], |
| 951 | + "source": [ |
| 952 | + "#| export\n", |
| 953 | + "class Channel:\n", |
| 954 | + " \"Duplex JSON messaging with the other peers on a named `/wsx` relay channel\"\n", |
| 955 | + " def __init__(self, chan, url=None):\n", |
| 956 | + " self.url = url or f\"ws://localhost:{dh_settings['port']}/wsx?chan={chan}\"\n", |
| 957 | + " self._replies,self.events = {},asyncio.Queue()\n", |
| 958 | + "\n", |
| 959 | + " @classmethod\n", |
| 960 | + " async def connect(cls,\n", |
| 961 | + " chan, # Channel name; peers connecting to the same name hear each other\n", |
| 962 | + " url=None # Full relay url (defaults to local solveit's `/wsx`)\n", |
| 963 | + " ):\n", |
| 964 | + " \"Connect to `chan` and start reading frames\"\n", |
| 965 | + " self = cls(chan, url=url)\n", |
| 966 | + " self._ws = await websockets.connect(self.url)\n", |
| 967 | + " self._reader = asyncio.create_task(self._read_loop())\n", |
| 968 | + " return self\n", |
| 969 | + "\n", |
| 970 | + " async def _read_loop(self):\n", |
| 971 | + " async for frame in self._ws:\n", |
| 972 | + " msg = dict2obj(json.loads(frame))\n", |
| 973 | + " if (f := self._replies.pop(msg.get('id'), None)): f.set_result(msg)\n", |
| 974 | + " else: await self.events.put(msg)\n", |
| 975 | + "\n", |
| 976 | + " async def send(self, **msg): await self._ws.send(json.dumps(msg))\n", |
| 977 | + "\n", |
| 978 | + " async def request(self, timeout=15, **msg):\n", |
| 979 | + " \"Send `msg` with a correlation `id` and await the reply frame bearing that id\"\n", |
| 980 | + " mid = msg['id'] = str(uuid.uuid4())\n", |
| 981 | + " f = asyncio.get_running_loop().create_future()\n", |
| 982 | + " self._replies[mid] = f\n", |
| 983 | + " try:\n", |
| 984 | + " await self._ws.send(json.dumps(msg))\n", |
| 985 | + " return await asyncio.wait_for(f, timeout)\n", |
| 986 | + " finally: self._replies.pop(mid, None)\n", |
| 987 | + "\n", |
| 988 | + " async def close(self):\n", |
| 989 | + " self._reader.cancel()\n", |
| 990 | + " await self._ws.close()\n", |
| 991 | + "\n", |
| 992 | + " @property\n", |
| 993 | + " def is_open(self): return self._ws.state.name == 'OPEN'" |
| 994 | + ] |
| 995 | + }, |
| 996 | + { |
| 997 | + "cell_type": "markdown", |
| 998 | + "id": "4f6edf81", |
| 999 | + "metadata": {}, |
| 1000 | + "source": [ |
| 1001 | + "A `Channel` connects to a named channel on solveit's `/wsx` relay, which forwards every frame verbatim to all other peers on the same name. Frames are JSON dicts: pass fields as kwargs to `send`, and receive them as `AttrDict`s. Frames that answer a pending `request` are matched up by their `id`; everything else goes to the `events` queue. The main peer today is the [solveit-chrome](https://github.com/AnswerDotAI/solveit-chrome) extension, which `solvecdp` drives through this class.\n", |
| 1002 | + "\n", |
| 1003 | + "Two peers on one channel: a plain `send` arrives in the other peer's `events`." |
| 1004 | + ] |
| 1005 | + }, |
| 1006 | + { |
| 1007 | + "cell_type": "code", |
| 1008 | + "execution_count": null, |
| 1009 | + "id": "5c5afd2b", |
| 1010 | + "metadata": {}, |
| 1011 | + "outputs": [], |
| 1012 | + "source": [ |
| 1013 | + "ch1,ch2 = await Channel.connect('test'),await Channel.connect('test')\n", |
| 1014 | + "await ch1.send(hello='world')\n", |
| 1015 | + "test_eq((await ch2.events.get()).hello, 'world')" |
| 1016 | + ] |
| 1017 | + }, |
| 1018 | + { |
| 1019 | + "cell_type": "markdown", |
| 1020 | + "id": "44ae366c", |
| 1021 | + "metadata": {}, |
| 1022 | + "source": [ |
| 1023 | + "`request` adds an `id` to the frame and waits for the reply with the same `id`, so several requests can be in flight at once. Here the second peer answers, echoing the `id` back with a result:" |
| 1024 | + ] |
| 1025 | + }, |
| 1026 | + { |
| 1027 | + "cell_type": "code", |
| 1028 | + "execution_count": null, |
| 1029 | + "id": "6e191ec9", |
| 1030 | + "metadata": {}, |
| 1031 | + "outputs": [], |
| 1032 | + "source": [ |
| 1033 | + "async def _responder():\n", |
| 1034 | + " msg = await ch2.events.get()\n", |
| 1035 | + " await ch2.send(id=msg.id, result=msg.x*2)\n", |
| 1036 | + "\n", |
| 1037 | + "task = asyncio.create_task(_responder())\n", |
| 1038 | + "test_eq((await ch1.request(x=21)).result, 42)" |
| 1039 | + ] |
| 1040 | + }, |
| 1041 | + { |
| 1042 | + "cell_type": "markdown", |
| 1043 | + "id": "8354e9fe", |
| 1044 | + "metadata": {}, |
| 1045 | + "source": [ |
| 1046 | + "`is_open` shows whether the connection is still up:" |
| 1047 | + ] |
| 1048 | + }, |
| 1049 | + { |
| 1050 | + "cell_type": "code", |
| 1051 | + "execution_count": null, |
| 1052 | + "id": "f24d7639", |
| 1053 | + "metadata": {}, |
| 1054 | + "outputs": [], |
| 1055 | + "source": [ |
| 1056 | + "assert ch1.is_open\n", |
| 1057 | + "await ch1.close()\n", |
| 1058 | + "await ch2.close()\n", |
| 1059 | + "assert not ch1.is_open" |
| 1060 | + ] |
| 1061 | + }, |
936 | 1062 | { |
937 | 1063 | "cell_type": "code", |
938 | 1064 | "execution_count": null, |
|
0 commit comments