sandboxedjs 0.2.12 → 0.2.13

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.
Binary file
@@ -18,9 +18,9 @@
18
18
  "artifacts": {
19
19
  "moduleUrl": "./python.js",
20
20
  "hashes": {
21
- "python.js": "sha256-ba225e5b4900449d493f213dc67263ed1afa7497f36582c43d778ccc282fe749",
22
- "python.wasm": "sha256-a971913c97e671ec281c92c2922e0c64b3e2c9565c58a466a9dda78df5bb8e7a",
23
- "python.data": "sha256-98d1fd943b6530e6ac7dd816420efe81082510da14c49bdc06fa60f5b08b924e",
21
+ "python.js": "sha256-2077b7c944097fa32fd80ffdd2575d5877a75bea77a0083f3dce93e7d2ab2976",
22
+ "python.wasm": "sha256-03f5cb9889a26d5f0b5a3e9d7310e2c5d9f2e8d1c4f9673b061ee19fcbe759c1",
23
+ "python.data": "sha256-c6e6bcf4afd43d12b50c6a0d013d400868434bbfd37341d4642ea40ad69cacd3",
24
24
  "python.worker.js": "sha256-63bb1df52c4e3a95eae7b2d77c98554188fcc5925e72628ea0c34d5c573977a5"
25
25
  }
26
26
  },
@@ -802,6 +802,7 @@ async function runPythonProcess(start, port) {
802
802
  )).default;
803
803
  let exitCode = 0;
804
804
  let guestMemory;
805
+ let threadBridgeName;
805
806
  const guestHeap = () => {
806
807
  const exported = config.HEAPU8;
807
808
  if (exported && exported.byteLength > 0) return exported;
@@ -886,6 +887,7 @@ async function runPythonProcess(start, port) {
886
887
  if (!guestMemory && result.instance.exports.memory instanceof WebAssembly.Memory) {
887
888
  guestMemory = result.instance.exports.memory;
888
889
  }
890
+ threadBridgeName = result.instance.exports.sbx_thread_bridge_name;
889
891
  receiveInstance(result.instance, result.module);
890
892
  } catch (error) {
891
893
  config.onAbort?.(error);
@@ -903,6 +905,13 @@ async function runPythonProcess(start, port) {
903
905
  mountContainer(config.FS, client, start.mounts, config.ERRNO_CODES ?? {});
904
906
  bindStdio(config.FS, client, start.isTty);
905
907
  for (const [key, value] of Object.entries(start.env)) config.ENV[key] = value;
908
+ if (start.threadChannel && typeof threadBridgeName === "function") {
909
+ const bytes = new TextEncoder().encode(start.threadChannel + "\0");
910
+ if (bytes.length > 128) throw new Error("Python thread channel name is too long");
911
+ guestHeap().set(bytes, threadBridgeName());
912
+ port.postMessage({ type: "thread-memory", memory: guestMemory });
913
+ config.ENV.SBX_THREAD_HOST_CALLS = "1";
914
+ }
906
915
  try {
907
916
  config.FS.chdir(start.cwd);
908
917
  } catch {
@@ -99,44 +99,34 @@ carry a TLS handshake this transport cannot terminate. A page's `fetch` is also
99
99
  subject to CORS, so a cross-origin target must send the headers that let the
100
100
  page read the response; that is a browser rule, not a container policy.
101
101
 
102
- ## Blocking syscalls stop every thread
103
-
104
- Threads in this runtime are real and run in parallel: a worker thread happily
105
- burns CPU while the main thread sits in `time.sleep`, which releases the GIL.
106
- What they cannot do is make a syscall while another thread is inside one.
107
-
108
- The cause is in the interpreter image, not in this package. It is built with
109
- `-sPROXY_TO_PTHREAD` over a JavaScript-backed filesystem, so every syscall made
110
- on a thread is forwarded to the single thread that owns that filesystem -- the
111
- generated glue is full of `if (ENVIRONMENT_IS_PTHREAD) return
112
- proxyToMainThread(...)`. That thread answers a host call by blocking in
113
- `Atomics.wait`. While it is blocked it cannot service anybody else's proxied
114
- syscall, so one thread parked in a blocking read freezes every other thread's
115
- I/O until it returns.
116
-
117
- `asyncio.to_thread` is the case that meets this head-on: the loop parks in a
118
- blocking `select`, and waking it requires the worker thread to make a syscall
119
- that the parked thread would have to service. Neither completes.
120
- `to_thread` now raises immediately, naming the limitation, instead of hanging
121
- until the process is killed. That is a mitigation, not a fix.
122
-
123
- Three earlier explanations of this were wrong and are recorded here so the next
124
- person does not re-derive them: it is not a single control slot in the transport
125
- (threads do run concurrently), it is not the file-scope statics in `sbx_call`
126
- (they are downstream of the proxying), and it is not a missing per-thread
127
- channel on its own (a channel per thread does nothing while the syscalls are
128
- still funnelled to one thread).
129
-
130
- Fixing it is a change to how the runtime image is built, and there are two
131
- routes, neither small:
132
-
133
- - Stack switching. Suspend the wasm stack on a blocking call and return to the
134
- event loop, instead of holding the thread in `Atomics.wait`. This is what the
135
- Pyodide backend did through `run_sync`, and it is why that backend did not
136
- have this problem. It needs JSPI and a transport rewritten around suspension.
137
- - Unproxied per-thread syscalls. Give each thread its own channel and host
138
- server and stop routing syscalls through one thread. This fights the
139
- JavaScript filesystem the image is built on, since that filesystem lives on
140
- one thread by construction.
141
-
142
- Both are runtime-image work measured in days, not a patch plus a rebuild.
102
+ ## Python thread offloads
103
+
104
+ The bundled pthread interpreter supports standard `asyncio.to_thread`,
105
+ `run_in_executor`, and AnyIO's thread pool, including synchronous FastAPI routes.
106
+ These execute on real Python worker threads, preserving context propagation,
107
+ exceptions, and cancellation of the awaiting task.
108
+
109
+ Native socket calls release the GIL and send requests directly from each pthread
110
+ to the process supervisor over a private BroadcastChannel. Every outstanding
111
+ request owns a shared response buffer, so a waiting interpreter cannot prevent
112
+ another thread from completing network I/O. The supervisor still applies the
113
+ same descriptor ownership and outbound network policy.
114
+
115
+ Emscripten filesystem operations still proxy through the interpreter thread.
116
+ While other Python threads exist, the asyncio selector limits each wait to
117
+ 10 ms and briefly yields, allowing those queued operations to run. This prevents
118
+ the old deadlock between a sleeping selector and a worker trying to wake it.
119
+ It adds polling overhead while the thread pool is alive.
120
+
121
+ Deploy the matching `python-worker.js` and the complete `python/` runtime directory
122
+ together. The worker detects the native bridge before enabling this behavior.
123
+ Older custom interpreter images retain the previous explicit `to_thread` error
124
+ and inline AnyIO fallback rather than silently entering a known deadlock.
125
+
126
+ Regression coverage includes context variables, exceptions, cancellation, file
127
+ I/O, simultaneous thread HTTP calls into an asyncio server, executor shutdown,
128
+ and FastAPI synchronous endpoints. The browser fixture also offloads a file read
129
+ and an outbound HTTPX request from a FastAPI streaming endpoint. This does not
130
+ certify arbitrary native extensions or indefinite blocking filesystem operations;
131
+ thread churn and long-running cleanup soak remain release gates. Browser egress
132
+ restrictions still apply independently of threading.
@@ -13,7 +13,7 @@ are proposed targets, not measured results.
13
13
  | M5 | native packages | Pydantic core and one small extension survive repeated import/exit | **done for the current ABI** — C and PyO3 side-module wheels load through ordinary CPython imports; `pydantic==2.9.0` selects and runs with `pydantic-core==2.23.2` |
14
14
  | M6 | local networking | echo server, concurrent connections, timeouts, port reuse | **partial** — the host-owned virtual TCP stack (`src/net/virtual-socket.ts`), its socket ABI family (`1792`), and router integration are done and cover `box.request()` to a non-JS listener plus cross-stack `EADDRINUSE` (`test/virtual-socket.test.ts`, `test/python-socket-abi.test.ts`, `test/universal-server.test.ts`); the CPython `_sbx_socket` adapter and `socket`/`selectors` patches are written but not yet built into the interpreter, so no Python process has bound a port through it |
15
15
  | M7 | web serving | FastAPI validation, streaming, WebSocket, shutdown | **partial** — a real FastAPI/Pydantic 2 application validates and answers through ASGI; Uvicorn cannot yet serve through `box.request()` |
16
- | M8 | threads | sync endpoints, AnyIO operations, thread cleanup | **partial** — pthread CPython, `asyncio.to_thread()`, and a synchronous FastAPI endpoint pass; churn/cleanup soak is outstanding |
16
+ | M8 | threads | sync endpoints, AnyIO operations, thread cleanup | **partial** — pthread CPython, `asyncio.to_thread()`, context/cancellation/file I/O, concurrent thread sockets, executor shutdown, and synchronous FastAPI endpoints pass; browser full-stack coverage is in `test/fixtures/fullstack-browser`; churn/cleanup soak is outstanding |
17
17
  | M9 | persistence | recovery after an interrupted write or install | not started |
18
18
  | M10 | ML | correct inference, bounded memory, cancellation | not started |
19
19
  | M11 | source builds | compile, install and import a representative C extension in-browser | not started |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandboxedjs",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "A Linux-like container that runs entirely inside Node.js — POSIX shell, ~140 coreutils, Node.js and Python runtimes, virtual filesystem and networking. No Docker, no VM, no native modules.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -94,6 +94,7 @@
94
94
  "process": "^0.11.10",
95
95
  "querystring-es3": "^0.2.1",
96
96
  "resolve.exports": "^2.0.3",
97
+ "sandboxedjs": "^0.2.12",
97
98
  "semver": "^7.8.5",
98
99
  "set-cookie-parser": "^3.1.2",
99
100
  "stream-browserify": "^3.0.0",