From 2c7ee2abfc79ead8b15216e7893f461f66870b8e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 9 Aug 2024 11:00:50 +0100 Subject: [PATCH] Fixing coding error around checking for empty chuunks --- examples/screenshot.svg | 213 +++++++++++++++++++++++++ pyproject.toml | 2 +- requirements-dev.lock | 11 +- requirements.lock | 11 +- src/textual_serve/app_service.py | 6 +- src/textual_serve/download_manager.py | 6 +- src/textual_serve/server.py | 12 +- src/textual_serve/static/js/textual.js | 2 +- 8 files changed, 251 insertions(+), 12 deletions(-) create mode 100644 examples/screenshot.svg diff --git a/examples/screenshot.svg b/examples/screenshot.svg new file mode 100644 index 0000000..e88b88c --- /dev/null +++ b/examples/screenshot.svg @@ -0,0 +1,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ScreenshotApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Hello, World!  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 74d6f94..c51fbc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "aiohttp>=3.9.5", "aiohttp-jinja2>=1.6", "jinja2>=3.1.4", - "textual>=0.66.0", + "textual[syntax] @ file:///Users/darrenburns/Code/textual-serve/../textual", "msgpack>=1.0.8", "rich", "msgpack-types>=0.3.0", diff --git a/requirements-dev.lock b/requirements-dev.lock index 8df2ab0..92794bf 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -58,13 +58,17 @@ mdit-py-plugins==0.4.1 mdurl==0.1.2 # via markdown-it-py msgpack==1.0.8 + # via textual # via textual-dev # via textual-serve msgpack-types==0.3.0 + # via textual # via textual-serve multidict==6.0.5 # via aiohttp # via yarl +platformdirs==4.2.2 + # via textual pygments==2.18.0 # via rich rich==13.7.1 @@ -73,10 +77,15 @@ rich==13.7.1 sniffio==1.3.1 # via anyio # via httpx -textual==0.75.1 +textual @ file:///Users/darrenburns/Code/textual-serve/../textual # via textual-dev # via textual-serve textual-dev==1.5.1 +tree-sitter==0.20.4 + # via textual + # via tree-sitter-languages +tree-sitter-languages==1.10.2 + # via textual typing-extensions==4.12.2 # via anyio # via rich diff --git a/requirements.lock b/requirements.lock index 155a270..6bfc2a1 100644 --- a/requirements.lock +++ b/requirements.lock @@ -41,19 +41,28 @@ mdit-py-plugins==0.4.1 mdurl==0.1.2 # via markdown-it-py msgpack==1.0.8 + # via textual # via textual-serve msgpack-types==0.3.0 + # via textual # via textual-serve multidict==6.0.5 # via aiohttp # via yarl +platformdirs==4.2.2 + # via textual pygments==2.18.0 # via rich rich==13.7.1 # via textual # via textual-serve -textual==0.75.1 +textual @ file:///Users/darrenburns/Code/textual-serve/../textual # via textual-serve +tree-sitter==0.20.4 + # via textual + # via tree-sitter-languages +tree-sitter-languages==1.10.2 + # via textual typing-extensions==4.12.2 # via rich # via textual diff --git a/src/textual_serve/app_service.py b/src/textual_serve/app_service.py index 559cae7..04c0d1c 100644 --- a/src/textual_serve/app_service.py +++ b/src/textual_serve/app_service.py @@ -20,7 +20,6 @@ from textual_serve.download_manager import DownloadManager log = logging.getLogger("textual-serve") -@rich.repr.auto class AppService: """Creates and manages a single Textual app subprocess. @@ -347,8 +346,9 @@ class AppService: payload: Encoded packed data. """ unpacked = msgpack.unpackb(payload) - if unpacked[0] == "deliver_file_chunk": + if unpacked[0] == "deliver_chunk": # If we receive a chunk, hand it to the download manager to # handle distribution to the browser. _, delivery_key, chunk_bytes = unpacked - await self._download_manager.chunk_received(self, delivery_key, chunk_bytes) + print("unpacked chunk:" + str(unpacked)) + await self._download_manager.chunk_received(delivery_key, chunk_bytes) diff --git a/src/textual_serve/download_manager.py b/src/textual_serve/download_manager.py index ab71d83..1981657 100644 --- a/src/textual_serve/download_manager.py +++ b/src/textual_serve/download_manager.py @@ -115,14 +115,16 @@ class DownloadManager: ) chunk = await incoming_chunks.get() - if chunk is None: + if not chunk: # The app process has finished sending the file. incoming_chunks.task_done() - raise StopAsyncIteration + break else: incoming_chunks.task_done() yield chunk + await asyncio.sleep(0.01) + async def chunk_received(self, delivery_key: str, chunk: bytes) -> None: """Handle a chunk received from the app service for a download. diff --git a/src/textual_serve/server.py b/src/textual_serve/server.py index ff09f87..a5a3a68 100644 --- a/src/textual_serve/server.py +++ b/src/textual_serve/server.py @@ -154,6 +154,7 @@ class Server: async def handle_download(self, request: web.Request) -> web.StreamResponse: """Handle a download request.""" + print("in download handler") key = request.match_info["key"] try: @@ -161,7 +162,7 @@ class Server: except KeyError: raise web.HTTPNotFound(text=f"Download with key {key!r} not found") - download_stream = self.download_manager.download(key) + print("download_meta:", download_meta) response = web.StreamResponse() response.headers["Content-Type"] = "application/octet-stream" @@ -169,13 +170,18 @@ class Server: "attachment" if download_meta.open_method == "download" else "inline" ) response.headers["Content-Disposition"] = ( - f"{disposition}; filename={download_meta.file_name}" + f"inline; filename={download_meta.file_name}" ) + await response.prepare(request) - async for chunk in download_stream: + async for chunk in self.download_manager.download(key): + print("writing chunk to response stream") await response.write(chunk) + await response.drain() + await asyncio.sleep(0.01) + print("=== writing eof") await response.write_eof() return response diff --git a/src/textual_serve/static/js/textual.js b/src/textual_serve/static/js/textual.js index 6f41fcb..18ef3ef 100644 --- a/src/textual_serve/static/js/textual.js +++ b/src/textual_serve/static/js/textual.js @@ -176,7 +176,7 @@ /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { "use strict"; - eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./styles.scss */ \"./src/styles.scss\");\n/* harmony import */ var xterm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! xterm */ \"./node_modules/xterm/lib/xterm.js\");\n/* harmony import */ var xterm__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(xterm__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var xterm_addon_fit__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! xterm-addon-fit */ \"./node_modules/xterm-addon-fit/lib/xterm-addon-fit.js\");\n/* harmony import */ var xterm_addon_fit__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(xterm_addon_fit__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var xterm_addon_webgl__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! xterm-addon-webgl */ \"./node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js\");\n/* harmony import */ var xterm_addon_webgl__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(xterm_addon_webgl__WEBPACK_IMPORTED_MODULE_3__);\n/* harmony import */ var xterm_addon_canvas__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! xterm-addon-canvas */ \"./node_modules/xterm-addon-canvas/lib/xterm-addon-canvas.js\");\n/* harmony import */ var xterm_addon_canvas__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(xterm_addon_canvas__WEBPACK_IMPORTED_MODULE_4__);\n/* harmony import */ var xterm_addon_unicode11__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! xterm-addon-unicode11 */ \"./node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js\");\n/* harmony import */ var xterm_addon_unicode11__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(xterm_addon_unicode11__WEBPACK_IMPORTED_MODULE_5__);\n\n\n\n\n\n\n\n\nclass TextualTerminal {\n constructor(element, options) {\n this.element = element;\n this.ping = options.ping;\n this.websocket_url = element.dataset.sessionWebsocketUrl;\n const font_size = element.dataset.fontSize;\n this.terminal = new xterm__WEBPACK_IMPORTED_MODULE_1__.Terminal({\n allowProposedApi: true,\n fontSize: font_size,\n scrollback: 0,\n // disableLigatures: true,\n // customGlyphs: true,\n fontFamily: \"'Roboto Mono', Monaco, 'Courier New', monospace\",\n });\n\n this.fitAddon = new xterm_addon_fit__WEBPACK_IMPORTED_MODULE_2__.FitAddon();\n this.terminal.loadAddon(this.fitAddon);\n this.webglAddon = new xterm_addon_webgl__WEBPACK_IMPORTED_MODULE_3__.WebglAddon();\n this.terminal.loadAddon(this.webglAddon);\n this.canvasAddon = new xterm_addon_canvas__WEBPACK_IMPORTED_MODULE_4__.CanvasAddon();\n this.terminal.loadAddon(this.canvasAddon);\n this.unicode11Addon = new xterm_addon_unicode11__WEBPACK_IMPORTED_MODULE_5__.Unicode11Addon();\n this.terminal.loadAddon(this.unicode11Addon);\n this.terminal.unicode.activeVersion = \"11\";\n this.terminal.open(element);\n\n this.socket = null;\n\n this.bufferedBytes = 0;\n this.refreshBytes = 0;\n this.size = null;\n\n this.terminal.element.querySelector(\".xterm-screen\").addEventListener(\n \"blur\",\n (event) => {\n this.onBlur();\n },\n true\n );\n\n this.terminal.element.querySelector(\".xterm-screen\").addEventListener(\n \"focus\",\n (event) => {\n this.onFocus();\n },\n true\n );\n\n this.terminal.onResize((event) => {\n this.size = { width: event.cols, height: event.rows };\n this.sendSize();\n });\n\n this.terminal.onData((data) => {\n this.socket.send(JSON.stringify([\"stdin\", data]));\n });\n\n window.onresize = () => {\n this.fit();\n };\n }\n\n sendSize() {\n if (this.size) {\n const meta = JSON.stringify([\"resize\", this.size]);\n if (this.socket) {\n this.socket.send(meta);\n }\n }\n }\n\n sendPing() {\n const epoch_milliseconds = new Date().getTime();\n const meta = JSON.stringify([\"ping\", \"\" + epoch_milliseconds]);\n if (this.socket) {\n this.socket.send(meta);\n }\n }\n\n onPong(pong_data) {\n const epoch_milliseconds = new Date().getTime();\n const delta = epoch_milliseconds - parseInt(pong_data);\n console.log(\"ping=\" + delta + \"ms\");\n }\n\n onFocus() {\n const meta = JSON.stringify([\"focus\"]);\n if (this.socket) {\n this.socket.send(meta);\n }\n }\n\n onBlur() {\n const meta = JSON.stringify([\"blur\"]);\n if (this.socket) {\n this.socket.send(meta);\n }\n }\n\n fit() {\n this.fitAddon.fit(this.element);\n }\n\n async connect() {\n if (this.ping) {\n await fetch(this.ping, {\n method: \"GET\",\n mode: \"no-cors\",\n });\n }\n\n this.fit();\n const initial_size = this.fitAddon.proposeDimensions();\n this.socket = new WebSocket(\n this.websocket_url +\n \"?width=\" +\n initial_size.cols +\n \"&height=\" +\n initial_size.rows\n );\n this.socket.binaryType = \"arraybuffer\";\n\n // Connection opened\n this.socket.addEventListener(\"open\", (event) => {\n this.element.classList.add(\"-connected\");\n this.fit();\n this.sendSize();\n\n setTimeout(() => {\n this.sendPing();\n }, 3);\n\n document.querySelector(\"body\").classList.add(\"-loaded\");\n });\n\n this.socket.addEventListener(\"close\", (event) => {\n console.log(\"CLOSED\");\n document.querySelector(\"body\").classList.add(\"-closed\");\n });\n\n // Listen for messages\n this.socket.addEventListener(\"message\", (event) => {\n if (typeof event.data === \"string\") {\n // String messages are encoded as JSON\n const packetData = JSON.parse(event.data);\n const packetType = packetData[0];\n const packetPayload = packetData[1];\n switch (packetType) {\n case \"log\":\n console.log(\"LOG\", packetPayload);\n break;\n case \"pong\":\n this.onPong(packetPayload);\n break;\n case \"open_url\":\n const url = packetPayload.url;\n const new_tab = packetPayload.new_tab;\n window.open(url, new_tab ? \"_blank\" : \"_self\");\n break;\n case \"deliver_file_start\":\n const deliveryKey = packetPayload\n const downloadUrl = `${window.location.origin}/download/${deliveryKey}`\n window.open(downloadUrl, \"_blank\");\n break;\n }\n } else {\n /* Binary messages are stdout data. */\n const bytearray = new Uint8Array(event.data);\n this.bufferedBytes += bytearray.length;\n this.refreshBytes += bytearray.length;\n this.terminal.write(bytearray, () => {\n this.bufferedBytes -= bytearray.length;\n });\n\n if (bytearray.length > 10) {\n document.querySelector(\"body\").classList.add(\"-first-byte\");\n }\n }\n });\n }\n}\n\nwindow.onload = (event) => {\n const terminals = document.querySelectorAll(\".textual-terminal\");\n const urlParams = new URLSearchParams(window.location.search);\n const delay = urlParams.get(\"delay\");\n const ping = urlParams.get(\"ping\");\n\n if (delay) {\n document.querySelector(\"#start\").classList.add(\"-delay\");\n }\n terminals.forEach((terminal_element) => {\n const textual_terminal = new TextualTerminal(terminal_element, {\n ping: ping,\n });\n textual_terminal.fit();\n\n if (!delay) {\n textual_terminal.connect();\n }\n });\n};\n\n\n//# sourceURL=webpack://textual/./src/index.js?"); + eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _styles_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./styles.scss */ \"./src/styles.scss\");\n/* harmony import */ var xterm__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! xterm */ \"./node_modules/xterm/lib/xterm.js\");\n/* harmony import */ var xterm__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(xterm__WEBPACK_IMPORTED_MODULE_1__);\n/* harmony import */ var xterm_addon_fit__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! xterm-addon-fit */ \"./node_modules/xterm-addon-fit/lib/xterm-addon-fit.js\");\n/* harmony import */ var xterm_addon_fit__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(xterm_addon_fit__WEBPACK_IMPORTED_MODULE_2__);\n/* harmony import */ var xterm_addon_webgl__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! xterm-addon-webgl */ \"./node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js\");\n/* harmony import */ var xterm_addon_webgl__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(xterm_addon_webgl__WEBPACK_IMPORTED_MODULE_3__);\n/* harmony import */ var xterm_addon_canvas__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! xterm-addon-canvas */ \"./node_modules/xterm-addon-canvas/lib/xterm-addon-canvas.js\");\n/* harmony import */ var xterm_addon_canvas__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(xterm_addon_canvas__WEBPACK_IMPORTED_MODULE_4__);\n/* harmony import */ var xterm_addon_unicode11__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! xterm-addon-unicode11 */ \"./node_modules/xterm-addon-unicode11/lib/xterm-addon-unicode11.js\");\n/* harmony import */ var xterm_addon_unicode11__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(xterm_addon_unicode11__WEBPACK_IMPORTED_MODULE_5__);\n\n\n\n\n\n\n\n\nclass TextualTerminal {\n constructor(element, options) {\n this.element = element;\n this.ping = options.ping;\n this.websocket_url = element.dataset.sessionWebsocketUrl;\n const font_size = element.dataset.fontSize;\n this.terminal = new xterm__WEBPACK_IMPORTED_MODULE_1__.Terminal({\n allowProposedApi: true,\n fontSize: font_size,\n scrollback: 0,\n // disableLigatures: true,\n // customGlyphs: true,\n fontFamily: \"'Roboto Mono', Monaco, 'Courier New', monospace\",\n });\n\n this.fitAddon = new xterm_addon_fit__WEBPACK_IMPORTED_MODULE_2__.FitAddon();\n this.terminal.loadAddon(this.fitAddon);\n this.webglAddon = new xterm_addon_webgl__WEBPACK_IMPORTED_MODULE_3__.WebglAddon();\n this.terminal.loadAddon(this.webglAddon);\n this.canvasAddon = new xterm_addon_canvas__WEBPACK_IMPORTED_MODULE_4__.CanvasAddon();\n this.terminal.loadAddon(this.canvasAddon);\n this.unicode11Addon = new xterm_addon_unicode11__WEBPACK_IMPORTED_MODULE_5__.Unicode11Addon();\n this.terminal.loadAddon(this.unicode11Addon);\n this.terminal.unicode.activeVersion = \"11\";\n this.terminal.open(element);\n\n this.socket = null;\n\n this.bufferedBytes = 0;\n this.refreshBytes = 0;\n this.size = null;\n\n this.terminal.element.querySelector(\".xterm-screen\").addEventListener(\n \"blur\",\n (event) => {\n this.onBlur();\n },\n true\n );\n\n this.terminal.element.querySelector(\".xterm-screen\").addEventListener(\n \"focus\",\n (event) => {\n this.onFocus();\n },\n true\n );\n\n this.terminal.onResize((event) => {\n this.size = { width: event.cols, height: event.rows };\n this.sendSize();\n });\n\n this.terminal.onData((data) => {\n this.socket.send(JSON.stringify([\"stdin\", data]));\n });\n\n window.onresize = () => {\n this.fit();\n };\n }\n\n sendSize() {\n if (this.size) {\n const meta = JSON.stringify([\"resize\", this.size]);\n if (this.socket) {\n this.socket.send(meta);\n }\n }\n }\n\n sendPing() {\n const epoch_milliseconds = new Date().getTime();\n const meta = JSON.stringify([\"ping\", \"\" + epoch_milliseconds]);\n if (this.socket) {\n this.socket.send(meta);\n }\n }\n\n onPong(pong_data) {\n const epoch_milliseconds = new Date().getTime();\n const delta = epoch_milliseconds - parseInt(pong_data);\n console.log(\"ping=\" + delta + \"ms\");\n }\n\n onFocus() {\n const meta = JSON.stringify([\"focus\"]);\n if (this.socket) {\n this.socket.send(meta);\n }\n }\n\n onBlur() {\n const meta = JSON.stringify([\"blur\"]);\n if (this.socket) {\n this.socket.send(meta);\n }\n }\n\n fit() {\n this.fitAddon.fit(this.element);\n }\n\n async connect() {\n if (this.ping) {\n await fetch(this.ping, {\n method: \"GET\",\n mode: \"no-cors\",\n });\n }\n\n this.fit();\n const initial_size = this.fitAddon.proposeDimensions();\n this.socket = new WebSocket(\n this.websocket_url +\n \"?width=\" +\n initial_size.cols +\n \"&height=\" +\n initial_size.rows\n );\n this.socket.binaryType = \"arraybuffer\";\n\n // Connection opened\n this.socket.addEventListener(\"open\", (event) => {\n this.element.classList.add(\"-connected\");\n this.fit();\n this.sendSize();\n\n setTimeout(() => {\n this.sendPing();\n }, 3);\n\n document.querySelector(\"body\").classList.add(\"-loaded\");\n });\n\n this.socket.addEventListener(\"close\", (event) => {\n console.log(\"CLOSED\");\n document.querySelector(\"body\").classList.add(\"-closed\");\n });\n\n // Listen for messages\n this.socket.addEventListener(\"message\", (event) => {\n if (typeof event.data === \"string\") {\n // String messages are encoded as JSON\n const packetData = JSON.parse(event.data);\n const packetType = packetData[0];\n const packetPayload = packetData[1];\n switch (packetType) {\n case \"log\":\n console.log(\"LOG\", packetPayload);\n break;\n case \"pong\":\n this.onPong(packetPayload);\n break;\n case \"open_url\":\n const url = packetPayload.url;\n const new_tab = packetPayload.new_tab;\n window.open(url, new_tab ? \"_blank\" : \"_self\");\n break;\n case \"deliver_file_start\":\n const deliveryKey = packetPayload\n const downloadUrl = `${window.location.origin}/download/${deliveryKey}`\n console.log(\"opening download url\", downloadUrl);\n window.open(downloadUrl, \"_blank\");\n break;\n }\n } else {\n /* Binary messages are stdout data. */\n const bytearray = new Uint8Array(event.data);\n this.bufferedBytes += bytearray.length;\n this.refreshBytes += bytearray.length;\n this.terminal.write(bytearray, () => {\n this.bufferedBytes -= bytearray.length;\n });\n\n if (bytearray.length > 10) {\n document.querySelector(\"body\").classList.add(\"-first-byte\");\n }\n }\n });\n }\n}\n\nwindow.onload = (event) => {\n const terminals = document.querySelectorAll(\".textual-terminal\");\n const urlParams = new URLSearchParams(window.location.search);\n const delay = urlParams.get(\"delay\");\n const ping = urlParams.get(\"ping\");\n\n if (delay) {\n document.querySelector(\"#start\").classList.add(\"-delay\");\n }\n terminals.forEach((terminal_element) => {\n const textual_terminal = new TextualTerminal(terminal_element, {\n ping: ping,\n });\n textual_terminal.fit();\n\n if (!delay) {\n textual_terminal.connect();\n }\n });\n};\n\n\n//# sourceURL=webpack://textual/./src/index.js?"); /***/ })