Skip to content

Commit 4ca73ab

Browse files
bajrangCoderclaude
andcommitted
fix: address SDL review feedback
- Ask before running a pygame program on Acode's main page, where it can reach the app (Python there is not isolated like the worker); declining runs it in the worker without a display, with an option to not ask again - Do not start a game that was stopped while the SDL runtime or its packages were still loading, including while sdl.js itself loads - Detect real await expressions with ast instead of matching the word, and stop blocking loops that cannot pause (no JSPI) after a few frames with a clear error instead of freezing Acode - dispose() on unmount restores window.fetch (or makes the wrapper a pass-through if another plugin wrapped it since) and removes the iframe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5e4eb05 commit 4ca73ab

6 files changed

Lines changed: 135 additions & 18 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ while running:
7272
pygame.quit()
7373
```
7474

75+
- The display runs your program on Acode's main page instead of the isolated worker, so it could access the app and its data. The plugin asks before doing this (you can choose not to be asked again); only allow it for code you trust.
7576
- Blocking loops like this need Android System WebView 137 or newer (JSPI support). On older WebViews, make the loop async with `await asyncio.sleep(0)` each frame.
7677
- Running again or closing the console stops the game.
7778
- If SDL cannot start, the program runs without a display and the console explains why.

‎changelog.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
- Packages such as `numpy`, `pandas`, `matplotlib` and `micropip` are downloaded automatically when imported (internet needed on first use)
99
- matplotlib plots are shown in the console, on `plt.show()` or automatically when the program ends. Plots are sized to the console and rendered sharp on high-density screens
1010
- Pillow's `Image.show()` displays images in the console
11-
- Experimental pygame support: games draw to a canvas in the console, including ordinary blocking game loops on WebViews with JSPI (Android System WebView 137+). If SDL cannot start, the program runs without a display and the reason is shown
11+
- Experimental pygame support: games draw to a canvas in the console, including ordinary blocking game loops on WebViews with JSPI (Android System WebView 137+). Asks before running on Acode's main page. If SDL cannot start, the program runs without a display and the reason is shown
1212
- Header button to toggle line wrap in the console (remembered between sessions)
1313
- Thin progress line under the console header while Python is loading or code is running
1414

‎plugin.zip‎

3.82 KB
Binary file not shown.

‎src/main.js‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import plugin from "../plugin.json";
22
import style from "./style.css";
33

44
const WRAP_KEY = `${plugin.id}.wrap`;
5+
const SDL_ALLOWED_KEY = `${plugin.id}.sdlAllowed`;
56
// Material "wrap_text" icon (Apache-2.0); Acode's icon font has no wrap glyph
67
const WRAP_ICON =
78
'<svg viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M4 19h6v-2H4v2zM20 5H4v2h16V5zm-3 6H4v2h13.25c1.1 0 2 .9 2 2s-.9 2-2 2H15v-2l-3 3 3 3v-2h2c2.21 0 4-1.79 4-4s-1.79-4-4-4z"/></svg>';
@@ -32,6 +33,9 @@ class Python {
3233
/** number of code runs still waiting for the worker */
3334
#running = 0;
3435
#sdlPromise = null;
36+
/** bumped by every run and when the console closes, to drop stale runs */
37+
#runId = 0;
38+
#sdlAllowed = false;
3539

3640
INITIALIZING = 1;
3741
INITIALIZED = 2;
@@ -51,6 +55,7 @@ class Python {
5155

5256
const onhide = $page.onhide;
5357
$page.onhide = () => {
58+
this.#runId += 1;
5459
window.acodePythonSdl?.stop();
5560
this.#state = this.NOT_INTIALIZED;
5661
this.#worker?.terminate();
@@ -86,7 +91,7 @@ class Python {
8691
// thin progress line along the header while Python loads or code runs
8792
this.$status = tag("div", { className: "py-progress" });
8893
this.$page.header?.append(this.$status, this.$wrapBtn);
89-
this.#setWrap(loadWrap());
94+
this.#setWrap(loadFlag(WRAP_KEY));
9095
this.$style = tag("style", { textContent: style });
9196
this.$input = tag("div", {
9297
className: "print input",
@@ -154,30 +159,65 @@ class Python {
154159
}
155160

156161
async run() {
162+
const runId = ++this.#runId;
157163
window.acodePythonSdl?.stop();
158164
this.#showPage();
159165
this.#inputCount = 0;
160166
this.#append(this.$input);
161167
await this.#cacheFile.writeFile("");
162168

163169
const code = editorManager.editor.getValue();
164-
if (PYGAME_IMPORT.test(code) && (await this.#runWithSdl(code))) return;
170+
if (PYGAME_IMPORT.test(code)) {
171+
if (!(await this.#allowSdl())) {
172+
this.print("Running without the pygame display.", "info");
173+
} else if (await this.#runWithSdl(code, runId)) {
174+
return;
175+
}
176+
if (runId !== this.#runId) return;
177+
}
165178
await this.runCode(code);
166179
}
167180

181+
/**
182+
* The pygame display runs Python on Acode's own page, where it can reach
183+
* the app and its data, unlike the isolated worker. Ask before doing that.
184+
*/
185+
async #allowSdl() {
186+
if (this.#sdlAllowed || loadFlag(SDL_ALLOWED_KEY)) return true;
187+
188+
const confirm =
189+
acode.require?.("confirm") ??
190+
((title, message) =>
191+
Promise.resolve(window.confirm(`${title}\n\n${message}`)));
192+
const answer = await confirm(
193+
"Show pygame display?",
194+
"The experimental pygame display runs this program on Acode's main page, where it can access the app and its data. Only continue for code you trust. Cancel runs it without the display.",
195+
false,
196+
{ checkboxText: "Don't ask again", returnState: true },
197+
);
198+
// older Acode versions return a plain boolean
199+
const confirmed = typeof answer === "object" ? answer?.confirmed : answer;
200+
if (!confirmed) return false;
201+
this.#sdlAllowed = true;
202+
if (answer?.checked) saveFlag(SDL_ALLOWED_KEY, true);
203+
return true;
204+
}
205+
168206
/**
169207
* Runs a pygame program with the experimental SDL runtime.
170208
* @returns {Promise<boolean>} false if SDL is unavailable and the caller
171209
* should fall back to the worker
172210
*/
173-
async #runWithSdl(code) {
211+
async #runWithSdl(code, runId) {
174212
const $canvas = tag("canvas");
175213
const $media = tag("div", { className: "py-media", children: [$canvas] });
176214
this.#append($media, this.$input);
177215
this.#running += 1;
178216
this.#updateStatus();
179217
try {
180218
const sdl = await this.#loadSdl();
219+
// another run started or the console closed while sdl.js loaded
220+
if (runId !== this.#runId) return true;
181221
const error = await sdl.run(code, {
182222
baseUrl: this.baseUrl,
183223
canvas: $canvas,
@@ -261,7 +301,7 @@ class Python {
261301

262302
this.$wrapBtn?.remove();
263303
this.$status?.remove();
264-
window.acodePythonSdl?.stop();
304+
window.acodePythonSdl?.dispose?.();
265305
this.#worker?.terminate();
266306
editorManager.off("switch-file", this.checkRunnable.bind(this));
267307
editorManager.off("rename-file", this.checkRunnable.bind(this));
@@ -285,7 +325,7 @@ class Python {
285325
/** Wraps long output lines instead of scrolling them horizontally */
286326
#setWrap(wrap) {
287327
this.$page.classList.toggle("wrap", wrap);
288-
saveWrap(wrap);
328+
saveFlag(WRAP_KEY, wrap);
289329
}
290330

291331
print(res, type) {
@@ -494,19 +534,19 @@ class Python {
494534
}
495535
}
496536

497-
function loadWrap() {
537+
function loadFlag(key) {
498538
try {
499-
return localStorage.getItem(WRAP_KEY) === "true";
539+
return localStorage.getItem(key) === "true";
500540
} catch {
501541
return false;
502542
}
503543
}
504544

505-
function saveWrap(wrap) {
545+
function saveFlag(key, value) {
506546
try {
507-
localStorage.setItem(WRAP_KEY, String(wrap));
547+
localStorage.setItem(key, String(value));
508548
} catch {
509-
// the preference is a convenience, ignore storage failures
549+
// preferences are a convenience, ignore storage failures
510550
}
511551
}
512552

‎src/python/acode_pygame.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,32 @@
1717
_installed = False
1818
_stop_requested = False
1919

20+
# Without JSPI these calls cannot pause. An async game loop still lets the
21+
# event loop run between frames; a blocking one never does and would freeze
22+
# Acode, so it is stopped after a few frames without an event loop turn.
23+
MAX_PAUSES_WITHOUT_TURN = 10
24+
_waiting_for_turn = False
25+
_pauses_without_turn = 0
26+
2027

2128
class StopProgram(BaseException):
2229
"""Raised inside the game loop when the console stops the program."""
2330

2431

32+
class BlockingLoopError(RuntimeError):
33+
pass
34+
35+
2536
def reset():
2637
global _stop_requested
2738
_stop_requested = False
39+
_event_loop_turned()
40+
41+
42+
def _event_loop_turned():
43+
global _waiting_for_turn, _pauses_without_turn
44+
_waiting_for_turn = False
45+
_pauses_without_turn = 0
2846

2947

3048
def request_stop():
@@ -37,10 +55,26 @@ def _pause(seconds=0):
3755
raise StopProgram
3856
if can_run_sync():
3957
run_sync(asyncio.sleep(seconds))
58+
else:
59+
_check_not_blocking()
4060
if _stop_requested:
4161
raise StopProgram
4262

4363

64+
def _check_not_blocking():
65+
global _waiting_for_turn, _pauses_without_turn
66+
if not _waiting_for_turn:
67+
_waiting_for_turn = True
68+
asyncio.get_event_loop().call_soon(_event_loop_turned)
69+
return
70+
_pauses_without_turn += 1
71+
if _pauses_without_turn >= MAX_PAUSES_WITHOUT_TURN:
72+
raise BlockingLoopError(
73+
"This WebView cannot pause Python (no JSPI support), so the game "
74+
"loop must be async: add `await asyncio.sleep(0)` each frame."
75+
)
76+
77+
4478
def install():
4579
global _installed
4680
if _installed:

‎src/sdl.js‎

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SDL needs a real <canvas>, which a worker does not have, so this runtime
33
// runs on the main thread. Ordinary blocking game loops work through JSPI
44
// (see python/acode_pygame.py); without JSPI only async loops are safe.
5+
// Code here can reach Acode's page, so main.js asks the user first.
56
import {
67
flushFigures,
78
loadRuntime,
@@ -19,6 +20,18 @@ def start(code):
1920
acode_pygame.install()
2021
return asyncio.ensure_future(eval_code_async(code, {"__name__": "__main__"}))
2122
23+
def has_await(code):
24+
# real await expressions only, not the word in comments or strings
25+
import ast
26+
try:
27+
tree = ast.parse(code)
28+
except SyntaxError:
29+
return False
30+
return any(
31+
isinstance(node, (ast.Await, ast.AsyncFor, ast.AsyncWith))
32+
for node in ast.walk(tree)
33+
)
34+
2235
async def can_block():
2336
# only meaningful inside a task, where blocking code would run
2437
from pyodide.ffi import can_run_sync
@@ -52,7 +65,8 @@ let runner = null;
5265
/** the current run: its task handle and whether it was stopped */
5366
let current = null;
5467
let onFatal = () => {};
55-
let fetchRouted = false;
68+
/** set while window.fetch is routed, so dispose() can undo it */
69+
let fetchRoute = null;
5670

5771
/**
5872
* Other plugins may replace `window.fetch` on Acode's page, e.g. with native
@@ -63,8 +77,7 @@ let fetchRouted = false;
6377
* taken from a hidden same-origin iframe; everything else is untouched.
6478
*/
6579
function routeRuntimeFetch(baseUrl) {
66-
if (fetchRouted) return;
67-
fetchRouted = true;
80+
if (fetchRoute) return;
6881
const $frame = document.createElement("iframe");
6982
$frame.style.display = "none";
7083
$frame.setAttribute("aria-hidden", "true");
@@ -74,13 +87,27 @@ function routeRuntimeFetch(baseUrl) {
7487
const pageFetch = window.fetch;
7588
const prefixes = [baseUrl, PACKAGE_BASE_URL];
7689

77-
window.fetch = function (input, init) {
90+
const route = { $frame, pageFetch, disposed: false, fetch: null };
91+
route.fetch = function (input, init) {
7892
const url = typeof input === "string" ? input : (input?.url ?? `${input}`);
79-
if (prefixes.some((prefix) => url.startsWith(prefix))) {
93+
if (!route.disposed && prefixes.some((prefix) => url.startsWith(prefix))) {
8094
return browserFetch(input, init);
8195
}
8296
return pageFetch.call(this, input, init);
8397
};
98+
window.fetch = route.fetch;
99+
fetchRoute = route;
100+
}
101+
102+
/** Undoes routeRuntimeFetch when the plugin is unmounted */
103+
function unrouteRuntimeFetch() {
104+
if (!fetchRoute) return;
105+
// if another plugin wrapped fetch after us, restoring would drop their
106+
// wrapper, so leave ours in place as a plain pass-through instead
107+
if (window.fetch === fetchRoute.fetch) window.fetch = fetchRoute.pageFetch;
108+
fetchRoute.disposed = true;
109+
fetchRoute.$frame.remove();
110+
fetchRoute = null;
84111
}
85112

86113
function getRuntime(baseUrl) {
@@ -112,6 +139,7 @@ function getRuntime(baseUrl) {
112139
pyodide.runPython(RUNNER_SOURCE, { globals: namespace });
113140
runner = {
114141
start: namespace.get("start"),
142+
hasAwait: namespace.get("has_await"),
115143
canBlock: namespace.get("can_block"),
116144
stop: namespace.get("stop"),
117145
};
@@ -143,18 +171,22 @@ async function run(
143171
let pyodide;
144172
try {
145173
pyodide = await getRuntime(baseUrl);
174+
// stopped while the runtime was loading
175+
if (run.stopped) return null;
146176
canvas.id = "canvas";
147177
canvas.tabIndex = 0;
148178
pyodide.canvas.setCanvas2D(canvas);
149179
pyodide.runPython("import pygame\npygame.display.init()");
150180
// a blocking game loop needs JSPI to pause, or it freezes Acode
151-
if (!/\bawait\b/.test(code) && !(await runner.canBlock())) {
181+
if (!runner.hasAwait(code) && !(await runner.canBlock())) {
152182
throw new Error(
153183
"this WebView cannot pause Python (no JSPI support), so the game loop must be async: await asyncio.sleep(0) each frame",
154184
);
155185
}
186+
if (run.stopped) return null;
156187
} catch (error) {
157188
if (current === run) current = null;
189+
if (run.stopped) return null;
158190
throw new SdlUnavailableError(error?.message ?? String(error));
159191
}
160192

@@ -169,6 +201,8 @@ async function run(
169201
width,
170202
pixelRatio,
171203
});
204+
// stopped while packages were loading
205+
if (run.stopped) return null;
172206
const task = runner.start(code);
173207
// awaiting a proxy of a Python awaitable consumes and destroys it,
174208
// so keep a separate copy for stop() to cancel the task with
@@ -207,4 +241,12 @@ function stop() {
207241
}
208242
}
209243

210-
self.acodePythonSdl = { run, stop, SdlUnavailableError };
244+
/** Stops any program and undoes page changes when the plugin is unmounted */
245+
function dispose() {
246+
stop();
247+
unrouteRuntimeFetch();
248+
if (self.acodePythonSdl === api) delete self.acodePythonSdl;
249+
}
250+
251+
const api = { run, stop, dispose, SdlUnavailableError };
252+
self.acodePythonSdl = api;

0 commit comments

Comments
 (0)