gwchq-textjam 0.2.28 → 0.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -88,7 +88,7 @@ const PyodideWorker = () => {
88
88
  assets.pyodideBaseUrl = `${packageApiUrl}/pyodide.js`;
89
89
  importScripts(toAbsoluteFromOrigin(assets.pyodideBaseUrl));
90
90
 
91
- initialisePyodide();
91
+ initialisePyodide(data);
92
92
  };
93
93
 
94
94
  const supportsAllFeatures = typeof SharedArrayBuffer !== "undefined";
@@ -115,8 +115,24 @@ const PyodideWorker = () => {
115
115
  );
116
116
  }
117
117
  let pyodide, pyodidePromise, stdinBuffer, interruptBuffer, stopped;
118
+ let currentRunId = null;
119
+ let stdinStrategy = "sab";
120
+ let supportsJspi = false;
121
+ let stdinFallbackConfig = null;
122
+ const STDIN_CANCELLED_RESPONSE_CODE = 499;
123
+ const STDIN_TIMEOUT_RESPONSE_CODE = 408;
124
+ const STDIN_ABORTED_RESPONSE_CODE = 410;
125
+ const STDIN_ROUTE_NOT_FOUND_RESPONSE_CODE = 404;
126
+ // HTTP 204 No Content from the stdin Service Worker signals end-of-file —
127
+ // distinct from a cancellation/interrupt so input() raises EOFError and the
128
+ // user's `try/except EOFError` blocks keep working under the sync-XHR fallback.
129
+ const STDIN_EOF_RESPONSE_CODE = 204;
118
130
  /** When true, input() uses postMessage + run_sync(getInputAsync) instead of setStdin (requires JSPI). */
119
131
  let useMessageStdin = false;
132
+ /** When true, input() uses sync XHR via the Service Worker fallback. */
133
+ let useSyncXhrStdin = false;
134
+ /** True when current input request was cancelled from outside the worker. */
135
+ let stdinCancelled = false;
120
136
  /** Used when SharedArrayBuffer is unavailable: resolve for the pending input() call. */
121
137
  let pendingStdinResolve = null;
122
138
  // Until Pyodide is fully initialised, keep stdout/stderr in the dev console only.
@@ -167,6 +183,8 @@ const PyodideWorker = () => {
167
183
  break;
168
184
  }
169
185
  case "runPython":
186
+ currentRunId = data.runId || null;
187
+ stdinCancelled = false;
170
188
  runPython(data.python, data.userModuleNames);
171
189
  break;
172
190
  case "stopPython": {
@@ -179,6 +197,7 @@ const PyodideWorker = () => {
179
197
  pendingStdinResolve(null);
180
198
  pendingStdinResolve = null;
181
199
  }
200
+ stdinCancelled = true;
182
201
  break;
183
202
  }
184
203
  default: {
@@ -195,6 +214,19 @@ const PyodideWorker = () => {
195
214
  const runPython = async (python, userModuleNames) => {
196
215
  stopped = false;
197
216
 
217
+ if (stdinStrategy === "unavailable") {
218
+ postMessage({
219
+ method: "handleError",
220
+ file: "main.py",
221
+ line: "",
222
+ mistake: "",
223
+ type: "RuntimeError",
224
+ info: "Python input fallback is unavailable. Please enable and activate the stdin Service Worker, then reload the page.",
225
+ });
226
+ await clearPyodideData(userModuleNames);
227
+ return;
228
+ }
229
+
198
230
  // When stdin uses postMessage (no SharedArrayBuffer), run_sync() in input() requires
199
231
  // runPythonAsync so that JSPI stack switching can suspend until the main thread sends stdinResponse.
200
232
  const runUserCode = useMessageStdin
@@ -206,8 +238,37 @@ const PyodideWorker = () => {
206
238
  await runUserCode();
207
239
  });
208
240
  } catch (error) {
241
+ const isStdinControlError =
242
+ error?.message === "PYODIDE_STDIN_CANCELLED" ||
243
+ error?.message === "PYODIDE_STDIN_TIMEOUT" ||
244
+ error?.message === "PYODIDE_STDIN_ABORTED";
245
+
246
+ if (stdinCancelled || isStdinControlError) {
247
+ postMessage({
248
+ method: "handleError",
249
+ file: "main.py",
250
+ line: "",
251
+ mistake: "",
252
+ type: "KeyboardInterrupt",
253
+ info: "Execution interrupted",
254
+ });
255
+ await clearPyodideData(userModuleNames);
256
+ return;
257
+ }
258
+
209
259
  if (!(error instanceof pyodide.ffi.PythonError)) {
210
- throw error;
260
+ postMessage({
261
+ method: "handleError",
262
+ file: "main.py",
263
+ line: "",
264
+ mistake: "",
265
+ type: "RuntimeError",
266
+ info:
267
+ error?.message ||
268
+ "Python execution failed while reading stdin fallback input.",
269
+ });
270
+ await clearPyodideData(userModuleNames);
271
+ return;
211
272
  }
212
273
  const parsed = parsePythonError(error);
213
274
  // Stop resolves stdin with EOF so input() raises EOFError; show as interrupt, not error.
@@ -239,7 +300,14 @@ const PyodideWorker = () => {
239
300
  // Suppress internal loader output (e.g. "Loading pyodide-http") from the
240
301
  // user console while resolving imports and loading packages.
241
302
  suppressInternalStdStreams = true;
242
- const imports = await pyodide._api.pyodide_code.find_imports(python).toJs();
303
+
304
+ // `find_imports` returns a PyProxy whose .toJs() copies the contents but
305
+ // leaves the underlying Python object alive. Destroy it explicitly so JS
306
+ // GC can't try to finalise it later from a context where the GIL isn't
307
+ // held (which would surface as a NoGilError).
308
+ const importsProxy = pyodide._api.pyodide_code.find_imports(python);
309
+ const imports = importsProxy.toJs();
310
+ importsProxy.destroy();
243
311
 
244
312
  await pyodide.runPythonAsync(`
245
313
  import builtins
@@ -247,6 +315,18 @@ const PyodideWorker = () => {
247
315
  builtins.open = builtins._original_open
248
316
  `);
249
317
 
318
+ // Load packages sequentially rather than in Promise.all. Each
319
+ // `loadDependency` does multiple `await` hops (vendored .before(),
320
+ // loadPackage, micropip.install, …). When several run concurrently their
321
+ // await points interleave and Pyodide ends up servicing multiple in-flight
322
+ // package loads — that interleaving is the trigger for the NoGilError seen
323
+ // when complex projects load matplotlib's dependency tree. Sequential
324
+ // loading costs a few ms on cold start but matches how `loadPackage`
325
+ // already serialises its own work internally.
326
+ // for (const name of imports) {
327
+ // checkIfStopped();
328
+ // await loadDependency(name);
329
+ // }
250
330
  await Promise.all(imports.map((name) => loadDependency(name)));
251
331
 
252
332
  checkIfStopped();
@@ -358,9 +438,13 @@ const PyodideWorker = () => {
358
438
  }
359
439
 
360
440
  // If the import is for a module built into Python then do nothing.
441
+ // The PyProxy is only used as a presence check, so destroy it immediately
442
+ // — leaking it leaves a Python ref for JS GC to finalise later, which
443
+ // raises NoGilError when GC runs outside a GIL-held context.
361
444
  try {
362
445
  const pythonModule = pyodide.pyimport(pkgName);
363
446
  if (pythonModule) {
447
+ pythonModule.destroy();
364
448
  return;
365
449
  }
366
450
  } catch (_) {}
@@ -372,6 +456,7 @@ const PyodideWorker = () => {
372
456
 
373
457
  const pyodidePackage = pyodide.pyimport(pkgName);
374
458
  if (pyodidePackage) {
459
+ pyodidePackage.destroy();
375
460
  return;
376
461
  }
377
462
  } catch (_) {}
@@ -451,6 +536,10 @@ const PyodideWorker = () => {
451
536
  pyodidePackage = pyodide.pyimport("matplotlib");
452
537
  } catch (_) {}
453
538
  if (pyodidePackage) {
539
+ // Destroy the throwaway proxy before running follow-up Python — the
540
+ // proxy is only used as a "did matplotlib actually load?" gate, and
541
+ // leaking it would hand a Python ref to JS GC.
542
+ pyodidePackage.destroy();
454
543
  pyodide.runPython(`
455
544
  import matplotlib.pyplot as plt
456
545
  import io
@@ -601,6 +690,62 @@ const PyodideWorker = () => {
601
690
  },
602
691
  };
603
692
 
693
+ const readInputViaSyncXhr = (runId) => {
694
+ const requestId = crypto.randomUUID();
695
+ const requestUrl = new URL(
696
+ stdinFallbackConfig.endpointPath,
697
+ // eslint-disable-next-line no-restricted-globals
698
+ self.location.origin,
699
+ );
700
+ requestUrl.searchParams.set("runId", runId || "");
701
+ requestUrl.searchParams.set("requestId", requestId);
702
+ requestUrl.searchParams.set("clientId", stdinFallbackConfig.clientId);
703
+
704
+ const xhr = new XMLHttpRequest();
705
+ xhr.open("GET", requestUrl.toString(), false);
706
+ xhr.setRequestHeader("X-Pyodide-Stdin-Request", "true");
707
+ xhr.setRequestHeader("Cache-Control", "no-store");
708
+
709
+ try {
710
+ xhr.send(null);
711
+ } catch (_) {
712
+ throw new Error("Failed to read Python input via stdin fallback");
713
+ }
714
+
715
+ if (xhr.status === STDIN_CANCELLED_RESPONSE_CODE) {
716
+ stdinCancelled = true;
717
+ throw new Error("PYODIDE_STDIN_CANCELLED");
718
+ }
719
+
720
+ if (xhr.status === STDIN_TIMEOUT_RESPONSE_CODE) {
721
+ throw new Error("PYODIDE_STDIN_TIMEOUT");
722
+ }
723
+
724
+ if (xhr.status === STDIN_ABORTED_RESPONSE_CODE) {
725
+ throw new Error("PYODIDE_STDIN_ABORTED");
726
+ }
727
+
728
+ // EOF: orchestrator signalled Ctrl+D / no more input. Returning null tells
729
+ // Pyodide's line-based stdin handler to raise EOFError at the input() call
730
+ // site, rather than escalating to a KeyboardInterrupt that tears down the run.
731
+ if (xhr.status === STDIN_EOF_RESPONSE_CODE) {
732
+ return null;
733
+ }
734
+
735
+ if (xhr.status !== 200) {
736
+ throw new Error(
737
+ `Python input request failed with status ${xhr.status}: ${xhr.responseText}`,
738
+ );
739
+ }
740
+
741
+ let content = xhr.responseText ?? "";
742
+ if (!content.endsWith("\n")) {
743
+ content += "\n";
744
+ }
745
+
746
+ return content;
747
+ };
748
+
604
749
  const clearPyodideData = async (userModuleNames) => {
605
750
  postMessage({ method: "handleLoading" });
606
751
  try {
@@ -627,10 +772,17 @@ const PyodideWorker = () => {
627
772
  console.error("Error while clearing Pyodide data:", error);
628
773
  }
629
774
  console.log("clearPyodideData done");
630
- postMessage({ method: "handleLoaded", stdinBuffer, interruptBuffer });
775
+ postMessage({
776
+ method: "handleLoaded",
777
+ stdinBuffer,
778
+ interruptBuffer,
779
+ stdinStrategy,
780
+ supportsJspi,
781
+ stdinFallbackEnabled: Boolean(stdinFallbackConfig?.enabled),
782
+ });
631
783
  };
632
784
 
633
- const initialisePyodide = async () => {
785
+ const initialisePyodide = async (data) => {
634
786
  postMessage({ method: "handleLoading" });
635
787
 
636
788
  pyodidePromise = loadPyodide({
@@ -656,11 +808,30 @@ const PyodideWorker = () => {
656
808
 
657
809
  pyodide.registerJsModule("basthon", fakeBasthonPackage);
658
810
 
811
+ supportsJspi =
812
+ typeof WebAssembly?.Suspending === "function" &&
813
+ typeof WebAssembly?.promising === "function";
814
+
815
+ stdinFallbackConfig = {
816
+ enabled: Boolean(data?.stdinFallback?.enabled),
817
+ endpointPath: data?.stdinFallback?.endpointPath || "/pyodide-stdin",
818
+ clientId: data?.stdinFallback?.clientId || "",
819
+ };
820
+
659
821
  // When SharedArrayBuffer is unavailable, always use the postMessage-based
660
822
  // stdin path. JSPI / run_sync will raise at runtime if the environment
661
823
  // cannot stack-switch, but in JSPI-capable browsers this enables
662
824
  // interactive input() without COOP/COEP.
663
- useMessageStdin = !supportsAllFeatures;
825
+ useMessageStdin = !supportsAllFeatures && supportsJspi;
826
+ useSyncXhrStdin =
827
+ !supportsAllFeatures && !supportsJspi && stdinFallbackConfig.enabled;
828
+ stdinStrategy = supportsAllFeatures
829
+ ? "sab"
830
+ : useMessageStdin
831
+ ? "jspi"
832
+ : useSyncXhrStdin
833
+ ? "sync-xhr"
834
+ : "unavailable";
664
835
  pyodide.globals.set("__stdin_via_message__", useMessageStdin);
665
836
 
666
837
  await pyodide.runPythonAsync(`
@@ -703,13 +874,25 @@ const PyodideWorker = () => {
703
874
  interruptBuffer =
704
875
  interruptBuffer || new Uint8Array(new SharedArrayBuffer(1));
705
876
  pyodide.setInterruptBuffer(interruptBuffer);
877
+ } else if (useSyncXhrStdin) {
878
+ pyodide.setStdin({
879
+ stdin: () => readInputViaSyncXhr(currentRunId),
880
+ isatty: true,
881
+ });
706
882
  }
707
883
 
708
884
  // From this point on, anything written to stdout / stderr is considered
709
885
  // user-visible and will be forwarded to the UI.
710
886
  userStdStreamsEnabled = true;
711
887
 
712
- postMessage({ method: "handleLoaded", stdinBuffer, interruptBuffer });
888
+ postMessage({
889
+ method: "handleLoaded",
890
+ stdinBuffer,
891
+ interruptBuffer,
892
+ stdinStrategy,
893
+ supportsJspi,
894
+ stdinFallbackEnabled: stdinFallbackConfig.enabled,
895
+ });
713
896
  };
714
897
 
715
898
  const readFromStdin = (bufferToWrite) => {
@@ -0,0 +1,231 @@
1
+ /* eslint-disable no-restricted-globals */
2
+
3
+ const DEBUG_STDIN_SW = false;
4
+ const STDIN_ENDPOINT_PATH = "/pyodide-stdin";
5
+ const DEFAULT_INPUT_TIMEOUT_MS = 120000;
6
+ const CANCELLED_STATUS = 499;
7
+ const TIMEOUT_STATUS = 408;
8
+ const ABORTED_STATUS = 410;
9
+ // HTTP 204 = clean EOF (Ctrl+D, persistent stdinClosed). Distinguished from
10
+ // 499 cancel so the Pyodide worker can raise EOFError instead of KeyboardInterrupt.
11
+ const EOF_STATUS = 204;
12
+
13
+ /** @type {Map<string, { resolve: (response: Response) => void, timeoutId: number, runId: string, requestId: string, clientId: string }>} */
14
+ const pendingRequests = new Map();
15
+ /** @type {Map<string, string>} */
16
+ const tabClientToWindowClient = new Map();
17
+
18
+ const log = (...args) => {
19
+ if (!DEBUG_STDIN_SW) return;
20
+ console.log("[pyodide-stdin-sw]", ...args);
21
+ };
22
+
23
+ const pendingKey = (clientId, runId, requestId) =>
24
+ [clientId || "", runId || "", requestId || ""].join(":");
25
+
26
+ const noStoreHeaders = {
27
+ "Content-Type": "text/plain; charset=utf-8",
28
+ "Cache-Control": "no-store",
29
+ };
30
+
31
+ const responseWith = (status, body) =>
32
+ new Response(body, {
33
+ status,
34
+ headers: noStoreHeaders,
35
+ });
36
+
37
+ const finishPending = (key, status, body) => {
38
+ const pending = pendingRequests.get(key);
39
+ if (!pending) {
40
+ return false;
41
+ }
42
+
43
+ clearTimeout(pending.timeoutId);
44
+ pending.resolve(responseWith(status, body));
45
+ pendingRequests.delete(key);
46
+ return true;
47
+ };
48
+
49
+ const findPendingEntries = (clientId, runId, requestId) => {
50
+ const matches = [];
51
+ for (const [key, pending] of pendingRequests.entries()) {
52
+ if (clientId && pending.clientId !== clientId) continue;
53
+ if (runId && pending.runId !== runId) continue;
54
+ if (requestId && pending.requestId !== requestId) continue;
55
+ matches.push([key, pending]);
56
+ }
57
+ return matches;
58
+ };
59
+
60
+ self.addEventListener("install", (event) => {
61
+ log("install");
62
+ event.waitUntil(self.skipWaiting());
63
+ });
64
+
65
+ self.addEventListener("activate", (event) => {
66
+ log("activate");
67
+ event.waitUntil(self.clients.claim());
68
+ });
69
+
70
+ self.addEventListener("fetch", (event) => {
71
+ const requestUrl = new URL(event.request.url);
72
+ if (
73
+ event.request.method !== "GET" ||
74
+ requestUrl.pathname !== STDIN_ENDPOINT_PATH
75
+ ) {
76
+ return;
77
+ }
78
+
79
+ event.respondWith(handleStdinFetch(event, requestUrl));
80
+ });
81
+
82
+ const handleStdinFetch = async (event, requestUrl) => {
83
+ const runId = requestUrl.searchParams.get("runId") || "";
84
+ const requestId = requestUrl.searchParams.get("requestId") || "";
85
+ const tabClientId = requestUrl.searchParams.get("clientId") || "";
86
+ const timeoutMsRaw = Number.parseInt(
87
+ requestUrl.searchParams.get("timeoutMs") || "",
88
+ 10,
89
+ );
90
+ const timeoutMs =
91
+ Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0
92
+ ? timeoutMsRaw
93
+ : DEFAULT_INPUT_TIMEOUT_MS;
94
+
95
+ const fetchClientId = event.clientId || "";
96
+ const clientId = tabClientId || fetchClientId;
97
+
98
+ if (!runId || !requestId || !clientId) {
99
+ return responseWith(400, "Invalid stdin request identifiers");
100
+ }
101
+
102
+ const key = pendingKey(clientId, runId, requestId);
103
+ if (pendingRequests.has(key)) {
104
+ return responseWith(409, "Duplicate stdin request");
105
+ }
106
+
107
+ log("stdin request pending", { clientId, runId, requestId });
108
+
109
+ const responsePromise = new Promise((resolve) => {
110
+ const timeoutId = self.setTimeout(() => {
111
+ log("stdin request timeout", { clientId, runId, requestId });
112
+ finishPending(key, TIMEOUT_STATUS, "stdin request timed out");
113
+ }, timeoutMs);
114
+
115
+ pendingRequests.set(key, {
116
+ resolve,
117
+ timeoutId,
118
+ runId,
119
+ requestId,
120
+ clientId,
121
+ });
122
+ });
123
+
124
+ const registeredWindowClientId = tabClientToWindowClient.get(clientId) || "";
125
+ let targetClient = null;
126
+
127
+ if (registeredWindowClientId) {
128
+ targetClient = await self.clients.get(registeredWindowClientId);
129
+ }
130
+
131
+ if (!targetClient && fetchClientId) {
132
+ const fetchClient = await self.clients.get(fetchClientId);
133
+ if (fetchClient?.type === "window") {
134
+ targetClient = fetchClient;
135
+ }
136
+ }
137
+
138
+ const message = {
139
+ type: "PYODIDE_STDIN_REQUEST",
140
+ clientId,
141
+ runId,
142
+ requestId,
143
+ };
144
+
145
+ if (targetClient) {
146
+ targetClient.postMessage(message);
147
+ return responsePromise;
148
+ }
149
+
150
+ const matchedClients = await self.clients.matchAll({
151
+ includeUncontrolled: true,
152
+ });
153
+ const windowClients = matchedClients.filter(
154
+ (client) => client.type === "window",
155
+ );
156
+ if (!windowClients.length) {
157
+ finishPending(key, ABORTED_STATUS, "stdin client unavailable");
158
+ return responseWith(ABORTED_STATUS, "stdin client unavailable");
159
+ }
160
+
161
+ for (const client of windowClients) {
162
+ client.postMessage(message);
163
+ }
164
+
165
+ return responsePromise;
166
+ };
167
+
168
+ self.addEventListener("message", (event) => {
169
+ const data = event.data;
170
+ if (!data || typeof data !== "object") {
171
+ return;
172
+ }
173
+
174
+ const eventClientId =
175
+ event.source && "id" in event.source ? event.source.id : "";
176
+ const clientId = data.clientId || eventClientId || "";
177
+
178
+ if (data.type === "PYODIDE_STDIN_REGISTER") {
179
+ if (data.clientId && eventClientId) {
180
+ tabClientToWindowClient.set(String(data.clientId), String(eventClientId));
181
+ log("registered stdin tab client", {
182
+ clientId: String(data.clientId),
183
+ windowClientId: String(eventClientId),
184
+ });
185
+ }
186
+ return;
187
+ }
188
+
189
+ if (data.type === "PYODIDE_STDIN_RESPONSE") {
190
+ const key = pendingKey(clientId, data.runId, data.requestId);
191
+ const submitted = finishPending(key, 200, String(data.value ?? ""));
192
+ if (!submitted) {
193
+ log("stdin response for unknown request", {
194
+ clientId,
195
+ runId: data.runId,
196
+ requestId: data.requestId,
197
+ });
198
+ }
199
+ return;
200
+ }
201
+
202
+ if (data.type === "PYODIDE_STDIN_EOF") {
203
+ // EOF is targeted at a specific (runId, requestId) just like a normal response.
204
+ // The worker maps HTTP 204 → null, which Pyodide's line-based stdin treats as EOF.
205
+ const key = pendingKey(clientId, data.runId, data.requestId);
206
+ const submitted = finishPending(key, EOF_STATUS, "");
207
+ if (!submitted) {
208
+ log("stdin EOF for unknown request", {
209
+ clientId,
210
+ runId: data.runId,
211
+ requestId: data.requestId,
212
+ });
213
+ }
214
+ return;
215
+ }
216
+
217
+ if (data.type === "PYODIDE_STDIN_CANCEL") {
218
+ const matches = findPendingEntries(clientId, data.runId, data.requestId);
219
+ for (const [key] of matches) {
220
+ finishPending(key, CANCELLED_STATUS, "stdin request cancelled");
221
+ }
222
+
223
+ if (!matches.length) {
224
+ log("stdin cancel for unknown request", {
225
+ clientId,
226
+ runId: data.runId,
227
+ requestId: data.requestId,
228
+ });
229
+ }
230
+ }
231
+ });