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.
package/README.md CHANGED
@@ -256,6 +256,11 @@ from source where a wheel does not exist; browsers have no compiler, so they ask
256
256
  `npx sandboxedjs-build-wheels 4180`. See [docs/python/build-on-miss.md](docs/python/build-on-miss.md)
257
257
  and [compatibility](docs/python/compatibility.md).
258
258
 
259
+ **Python thread offloads work in the bundled runtime:** `asyncio.to_thread`,
260
+ `run_in_executor`, and synchronous FastAPI routes use real worker threads.
261
+ For browser deployments, update `python-worker.js` and the entire `python/`
262
+ directory together. See [threading architecture and limits](docs/browser-runtime-architecture.md#python-thread-offloads).
263
+
259
264
  **What does not work in a browser:** `copyIn()` / `copyOut()` and `expose()` (they need a real
260
265
  filesystem and a real socket), and the CLI. They fail only if you call them.
261
266
 
package/dist/index.cjs CHANGED
@@ -20027,7 +20027,7 @@ var CAPABILITIES = ["files", "descriptors", "pipes", "readiness", "time", "ident
20027
20027
  var OP_NAMES = Object.fromEntries(
20028
20028
  Object.entries(Op).map(([name, code]) => [code, name])
20029
20029
  );
20030
- Object.fromEntries(
20030
+ var ERRNO_NAMES = Object.fromEntries(
20031
20031
  Object.entries(Errno).map(([name, code]) => [code, name])
20032
20032
  );
20033
20033
 
@@ -21103,7 +21103,7 @@ var wheels_default = {
21103
21103
 
21104
21104
  // package.json
21105
21105
  var package_default = {
21106
- version: "0.2.12"};
21106
+ version: "0.2.13"};
21107
21107
 
21108
21108
  // src/python/extension-abi.ts
21109
21109
  var EXTENSION_ABI = {
@@ -21863,6 +21863,59 @@ var FileService = class {
21863
21863
  init_signals();
21864
21864
  init_binary();
21865
21865
 
21866
+ // src/python/thread-transport.ts
21867
+ init_binary();
21868
+ async function createThreadTransport(generation, handle) {
21869
+ const crypto2 = globalThis.crypto ?? (await nodeBuiltin("crypto")).webcrypto;
21870
+ const name = `sbx-python-${crypto2.randomUUID()}`;
21871
+ const channel = new BroadcastChannel(name);
21872
+ const pending = /* @__PURE__ */ new Set();
21873
+ let closed = false;
21874
+ let memory;
21875
+ let memoryReady;
21876
+ const ready = new Promise((resolve3) => {
21877
+ memoryReady = resolve3;
21878
+ });
21879
+ channel.onmessage = async ({ data }) => {
21880
+ if (closed || !(data?.request instanceof Uint8Array) || data.request.length < 16 || !Number.isSafeInteger(data.offset) || data.offset < 0 || data.offset % 4 || !Number.isSafeInteger(data.capacity) || data.capacity < 84) return;
21881
+ await ready;
21882
+ if (closed || !memory || data.offset + data.capacity > memory.buffer.byteLength) return;
21883
+ const control = new Int32Array(memory.buffer, data.offset, 1);
21884
+ const output = new Uint8Array(memory.buffer, data.offset, data.capacity);
21885
+ pending.add(control);
21886
+ try {
21887
+ const request = data.request.slice();
21888
+ new DataView(request.buffer).setUint32(8, generation, true);
21889
+ const response = await handle(request);
21890
+ if (closed) return;
21891
+ if (response.length > output.length - 64) throw new Error("thread response exceeds capacity");
21892
+ output.set(response, 64);
21893
+ const status = new DataView(response.buffer, response.byteOffset).getInt32(12, true);
21894
+ if (status < 0) output.set(new TextEncoder().encode(ERRNO_NAMES[-status] ?? "EIO"), 4);
21895
+ Atomics.store(control, 0, response.length);
21896
+ } catch {
21897
+ Atomics.store(control, 0, -1);
21898
+ } finally {
21899
+ pending.delete(control);
21900
+ Atomics.notify(control, 0);
21901
+ }
21902
+ };
21903
+ return { name, setMemory(value) {
21904
+ if (!(value.buffer instanceof SharedArrayBuffer)) throw new Error("Python thread memory must be shared");
21905
+ memory = value;
21906
+ memoryReady();
21907
+ }, close() {
21908
+ closed = true;
21909
+ memoryReady();
21910
+ channel.close();
21911
+ for (const control of pending) {
21912
+ Atomics.store(control, 0, -1);
21913
+ Atomics.notify(control, 0);
21914
+ }
21915
+ pending.clear();
21916
+ } };
21917
+ }
21918
+
21866
21919
  // src/python/protocol.ts
21867
21920
  var ProtocolError = class extends Error {
21868
21921
  code = "ERR_SBX_ABI_PROTOCOL";
@@ -23109,6 +23162,7 @@ async function startPythonProcess(options) {
23109
23162
  sockets: options.sockets,
23110
23163
  processes: options.processes
23111
23164
  });
23165
+ const threads = await createThreadTransport(generation, handle);
23112
23166
  const buffers = createHostTransportBuffers();
23113
23167
  let fault = null;
23114
23168
  const server = new HostCallServer(buffers, handle, (error) => {
@@ -23127,8 +23181,10 @@ async function startPythonProcess(options) {
23127
23181
  const cleanup = () => {
23128
23182
  if (done) return;
23129
23183
  done = true;
23184
+ controller.abort();
23130
23185
  options.signal.removeEventListener("abort", abort);
23131
23186
  server.close();
23187
+ threads.close();
23132
23188
  table.closeAll();
23133
23189
  try {
23134
23190
  worker?.terminate();
@@ -23143,6 +23199,10 @@ async function startPythonProcess(options) {
23143
23199
  }
23144
23200
  worker.onMessage((message) => {
23145
23201
  const detail = message;
23202
+ if (detail?.type === "thread-memory" && detail.memory) {
23203
+ threads.setMemory(detail.memory);
23204
+ return;
23205
+ }
23146
23206
  if (detail?.type === "wake") {
23147
23207
  void server.pump();
23148
23208
  return;
@@ -23175,6 +23235,7 @@ async function startPythonProcess(options) {
23175
23235
  type: "start",
23176
23236
  buffers,
23177
23237
  generation,
23238
+ threadChannel: threads.name,
23178
23239
  moduleUrl: options.manifest.artifacts.moduleUrl,
23179
23240
  argv: options.argv,
23180
23241
  env: options.env,
@@ -25301,7 +25362,30 @@ if _egress:
25301
25362
  sys.meta_path.insert(0, _SbxHttpxHook())
25302
25363
 
25303
25364
 
25304
- if os.environ.get("SBX_SERIAL_HOST_CALLS") == "1":
25365
+ if os.environ.get("SBX_THREAD_HOST_CALLS") == "1":
25366
+ import selectors as _selectors
25367
+ import threading as _threading
25368
+ import time as _time
25369
+
25370
+ class _SbxThreadSelector(_selectors.SelectSelector):
25371
+ """Let Emscripten service filesystem calls proxied by Python threads.
25372
+
25373
+ Native socket calls have an independent channel per request. Filesystem
25374
+ calls still use the main runtime thread, so it must periodically leave
25375
+ its host poll and yield whenever other Python threads are alive. Keep
25376
+ the standard asyncio executor, context propagation and cancellation.
25377
+ """
25378
+ def select(self, timeout=None):
25379
+ threaded = _threading.active_count() > 1
25380
+ if threaded:
25381
+ timeout = 0.01 if timeout is None else min(max(timeout, 0), 0.01)
25382
+ ready = super().select(timeout)
25383
+ if threaded:
25384
+ _time.sleep(0.001)
25385
+ return ready
25386
+
25387
+ _selectors.DefaultSelector = _SbxThreadSelector
25388
+ elif os.environ.get("SBX_SERIAL_HOST_CALLS") == "1":
25305
25389
  import asyncio
25306
25390
 
25307
25391
  async def _to_thread(func, /, *args, **kwargs):
package/dist/index.js CHANGED
@@ -20010,7 +20010,7 @@ var CAPABILITIES = ["files", "descriptors", "pipes", "readiness", "time", "ident
20010
20010
  var OP_NAMES = Object.fromEntries(
20011
20011
  Object.entries(Op).map(([name, code]) => [code, name])
20012
20012
  );
20013
- Object.fromEntries(
20013
+ var ERRNO_NAMES = Object.fromEntries(
20014
20014
  Object.entries(Errno).map(([name, code]) => [code, name])
20015
20015
  );
20016
20016
 
@@ -21086,7 +21086,7 @@ var wheels_default = {
21086
21086
 
21087
21087
  // package.json
21088
21088
  var package_default = {
21089
- version: "0.2.12"};
21089
+ version: "0.2.13"};
21090
21090
 
21091
21091
  // src/python/extension-abi.ts
21092
21092
  var EXTENSION_ABI = {
@@ -21846,6 +21846,59 @@ var FileService = class {
21846
21846
  init_signals();
21847
21847
  init_binary();
21848
21848
 
21849
+ // src/python/thread-transport.ts
21850
+ init_binary();
21851
+ async function createThreadTransport(generation, handle) {
21852
+ const crypto2 = globalThis.crypto ?? (await nodeBuiltin("crypto")).webcrypto;
21853
+ const name = `sbx-python-${crypto2.randomUUID()}`;
21854
+ const channel = new BroadcastChannel(name);
21855
+ const pending = /* @__PURE__ */ new Set();
21856
+ let closed = false;
21857
+ let memory;
21858
+ let memoryReady;
21859
+ const ready = new Promise((resolve3) => {
21860
+ memoryReady = resolve3;
21861
+ });
21862
+ channel.onmessage = async ({ data }) => {
21863
+ if (closed || !(data?.request instanceof Uint8Array) || data.request.length < 16 || !Number.isSafeInteger(data.offset) || data.offset < 0 || data.offset % 4 || !Number.isSafeInteger(data.capacity) || data.capacity < 84) return;
21864
+ await ready;
21865
+ if (closed || !memory || data.offset + data.capacity > memory.buffer.byteLength) return;
21866
+ const control = new Int32Array(memory.buffer, data.offset, 1);
21867
+ const output = new Uint8Array(memory.buffer, data.offset, data.capacity);
21868
+ pending.add(control);
21869
+ try {
21870
+ const request = data.request.slice();
21871
+ new DataView(request.buffer).setUint32(8, generation, true);
21872
+ const response = await handle(request);
21873
+ if (closed) return;
21874
+ if (response.length > output.length - 64) throw new Error("thread response exceeds capacity");
21875
+ output.set(response, 64);
21876
+ const status = new DataView(response.buffer, response.byteOffset).getInt32(12, true);
21877
+ if (status < 0) output.set(new TextEncoder().encode(ERRNO_NAMES[-status] ?? "EIO"), 4);
21878
+ Atomics.store(control, 0, response.length);
21879
+ } catch {
21880
+ Atomics.store(control, 0, -1);
21881
+ } finally {
21882
+ pending.delete(control);
21883
+ Atomics.notify(control, 0);
21884
+ }
21885
+ };
21886
+ return { name, setMemory(value) {
21887
+ if (!(value.buffer instanceof SharedArrayBuffer)) throw new Error("Python thread memory must be shared");
21888
+ memory = value;
21889
+ memoryReady();
21890
+ }, close() {
21891
+ closed = true;
21892
+ memoryReady();
21893
+ channel.close();
21894
+ for (const control of pending) {
21895
+ Atomics.store(control, 0, -1);
21896
+ Atomics.notify(control, 0);
21897
+ }
21898
+ pending.clear();
21899
+ } };
21900
+ }
21901
+
21849
21902
  // src/python/protocol.ts
21850
21903
  var ProtocolError = class extends Error {
21851
21904
  code = "ERR_SBX_ABI_PROTOCOL";
@@ -23092,6 +23145,7 @@ async function startPythonProcess(options) {
23092
23145
  sockets: options.sockets,
23093
23146
  processes: options.processes
23094
23147
  });
23148
+ const threads = await createThreadTransport(generation, handle);
23095
23149
  const buffers = createHostTransportBuffers();
23096
23150
  let fault = null;
23097
23151
  const server = new HostCallServer(buffers, handle, (error) => {
@@ -23110,8 +23164,10 @@ async function startPythonProcess(options) {
23110
23164
  const cleanup = () => {
23111
23165
  if (done) return;
23112
23166
  done = true;
23167
+ controller.abort();
23113
23168
  options.signal.removeEventListener("abort", abort);
23114
23169
  server.close();
23170
+ threads.close();
23115
23171
  table.closeAll();
23116
23172
  try {
23117
23173
  worker?.terminate();
@@ -23126,6 +23182,10 @@ async function startPythonProcess(options) {
23126
23182
  }
23127
23183
  worker.onMessage((message) => {
23128
23184
  const detail = message;
23185
+ if (detail?.type === "thread-memory" && detail.memory) {
23186
+ threads.setMemory(detail.memory);
23187
+ return;
23188
+ }
23129
23189
  if (detail?.type === "wake") {
23130
23190
  void server.pump();
23131
23191
  return;
@@ -23158,6 +23218,7 @@ async function startPythonProcess(options) {
23158
23218
  type: "start",
23159
23219
  buffers,
23160
23220
  generation,
23221
+ threadChannel: threads.name,
23161
23222
  moduleUrl: options.manifest.artifacts.moduleUrl,
23162
23223
  argv: options.argv,
23163
23224
  env: options.env,
@@ -25284,7 +25345,30 @@ if _egress:
25284
25345
  sys.meta_path.insert(0, _SbxHttpxHook())
25285
25346
 
25286
25347
 
25287
- if os.environ.get("SBX_SERIAL_HOST_CALLS") == "1":
25348
+ if os.environ.get("SBX_THREAD_HOST_CALLS") == "1":
25349
+ import selectors as _selectors
25350
+ import threading as _threading
25351
+ import time as _time
25352
+
25353
+ class _SbxThreadSelector(_selectors.SelectSelector):
25354
+ """Let Emscripten service filesystem calls proxied by Python threads.
25355
+
25356
+ Native socket calls have an independent channel per request. Filesystem
25357
+ calls still use the main runtime thread, so it must periodically leave
25358
+ its host poll and yield whenever other Python threads are alive. Keep
25359
+ the standard asyncio executor, context propagation and cancellation.
25360
+ """
25361
+ def select(self, timeout=None):
25362
+ threaded = _threading.active_count() > 1
25363
+ if threaded:
25364
+ timeout = 0.01 if timeout is None else min(max(timeout, 0), 0.01)
25365
+ ready = super().select(timeout)
25366
+ if threaded:
25367
+ _time.sleep(0.001)
25368
+ return ready
25369
+
25370
+ _selectors.DefaultSelector = _SbxThreadSelector
25371
+ elif os.environ.get("SBX_SERIAL_HOST_CALLS") == "1":
25288
25372
  import asyncio
25289
25373
 
25290
25374
  async def _to_thread(func, /, *args, **kwargs):