concurrent-c-node 0.22.0__tar.gz → 0.22.1__tar.gz

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.
Files changed (18) hide show
  1. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/PKG-INFO +113 -7
  2. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/README.md +107 -5
  3. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/cc_node/__init__.py +221 -13
  4. concurrent_c_node-0.22.1/cc_node/benchmarks/vs_alts.py +420 -0
  5. concurrent_c_node-0.22.1/cc_node/magics.py +65 -0
  6. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/concurrent_c_node.egg-info/PKG-INFO +113 -7
  7. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/concurrent_c_node.egg-info/SOURCES.txt +3 -0
  8. concurrent_c_node-0.22.1/concurrent_c_node.egg-info/requires.txt +3 -0
  9. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/pyproject.toml +9 -2
  10. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/cc_node/benchmarks/__init__.py +0 -0
  11. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/cc_node/benchmarks/multi_domain.py +0 -0
  12. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/cc_node/broker.cjs +0 -0
  13. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/cc_node/examples/__init__.py +0 -0
  14. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/cc_node/examples/bench_wire.py +0 -0
  15. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/cc_node/examples/use_node.py +0 -0
  16. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/concurrent_c_node.egg-info/dependency_links.txt +0 -0
  17. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/concurrent_c_node.egg-info/top_level.txt +0 -0
  18. {concurrent_c_node-0.22.0 → concurrent_c_node-0.22.1}/setup.cfg +0 -0
@@ -1,13 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: concurrent-c-node
3
- Version: 0.22.0
3
+ Version: 0.22.1
4
4
  Summary: JavaScript and npm packages from Python over the Concurrent-C bridge: one spawned Node child per domain, host-controlled lifetime.
5
5
  License: MIT
6
6
  Project-URL: Repository, https://github.com/sreekotay/concurrent-c
7
7
  Project-URL: Documentation, https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/README.md
8
- Keywords: javascript,node,npm,interop,concurrent-c
8
+ Keywords: javascript,node,npm,interop,concurrent-c,jupyter
9
+ Classifier: Framework :: IPython
10
+ Classifier: Framework :: Jupyter
9
11
  Requires-Python: >=3.8
10
12
  Description-Content-Type: text/markdown
13
+ Provides-Extra: jupyter
14
+ Requires-Dist: ipython>=7; extra == "jupyter"
11
15
 
12
16
  # concurrent-c-node
13
17
 
@@ -36,9 +40,12 @@ semver.satisfies('1.2.3', '^1.0.0') # True
36
40
  js.destroy() # or: with cc_node.create() as js:
37
41
  ```
38
42
 
39
- Unlike [`concurrent-c-python`](https://github.com/sreekotay/concurrent-c/tree/main/npm/cc-python)
40
- (in-process by default), every `create()` here is a separate Node —
41
- real addons, crash isolation, measurable wire. N domains = N processes.
43
+ The other direction (Python from Node):
44
+ [`concurrent-c-python`](https://www.npmjs.com/package/concurrent-c-python)
45
+ (in-process by default; vs pymport / ncp / pythonia in that README).
46
+
47
+ Every `create()` here is a separate Node — real addons, crash isolation,
48
+ measurable wire. N domains = N processes.
42
49
 
43
50
  | | this package | CC hosted (`cc_js_new(false, …)`) |
44
51
  |---|---|---|
@@ -49,13 +56,84 @@ real addons, crash isolation, measurable wire. N domains = N processes.
49
56
  | Parallelism | N children | one process |
50
57
  | Crash | child dies; parent lives | shared fate |
51
58
 
59
+ **Cheat sheet**
60
+
61
+ - Always a child `node`. A call blocks until JS answers; thenables wait
62
+ in the child. No `{ async: true }`.
63
+ - Scalars / `None` materialize; empty `{}` stays a handle; everything
64
+ else is a `JsHandle` until `str()` / attrs / a call.
65
+ - `%load_ext cc_node` then `%%js` — one kernel domain
66
+ (`cc_node.kernel()`), not a child per cell.
67
+ - `eval()` is one RTT, no extra globals. `%%js` / `eval_cell` install
68
+ cwd `require` once.
69
+ - `--bind` is `Object.assign(globalThis, …)` of names you name (wire
70
+ types only; no pickle). Missing names refuse.
71
+ - First Ctrl-C finishes the in-flight call (wire stays in sync).
72
+ Interrupt again to kill the child.
73
+
52
74
  ```
53
- pip install concurrent-c-node # needs node on PATH
75
+ pip install concurrent-c-node # needs node on PATH
76
+ pip install 'concurrent-c-node[jupyter]' # magics (IPython)
54
77
  python -m cc_node.examples.use_node
55
78
  python -m cc_node.examples.bench_wire
56
79
  python -m cc_node.benchmarks.multi_domain
80
+ python -m cc_node.benchmarks.vs_alts # vs DIY node / pythonmonkey / mini-racer
81
+ ```
82
+
83
+ ## Jupyter / Colab
84
+
85
+ Colab and the usual Jupyter kernel are **Python** — this package. Same
86
+ calling convention as a script: a cell blocks until Node answers;
87
+ thenables wait in the child. Magics share **one** domain for the
88
+ kernel, not a spawn per cell (~28ms).
89
+
90
+ ```python
91
+ %pip install concurrent-c-node
92
+ # if `node` is missing (typical Colab):
93
+ !apt-get install -y nodejs
94
+ %load_ext cc_node
95
+
96
+ %%js
97
+ const path = require('path')
98
+ path.join('a', 'b') // last expression comes back as Python
99
+ ```
100
+
101
+ ```python
102
+ xs = [1, 2, 3, 4]
103
+
104
+ %%js -b xs -t chunks
105
+ xs.map(x => x * 2) // wire types only; no pickle fallback
57
106
  ```
58
107
 
108
+ | | |
109
+ |---|---|
110
+ | `%load_ext cc_node` | register magics; does **not** spawn until first `%%js` |
111
+ | `%js 1+1` / `%%js` | eval on `cc_node.kernel()`; last expression is the result |
112
+ | `-b xs` / `--bind xs,n` | publish those Python names on `globalThis` for the cell |
113
+ | `-t chunks` / `--to` | store the result in the notebook namespace |
114
+ | `%js_stats` | handle-table size (spawns if needed) |
115
+ | `%js_reset` | `destroy()` the kernel child; next `%%js` spawns again |
116
+ | `cc_node.kernel()` | the same domain the magics use; `create()` is still a private child |
117
+ | `JsHandle` display | cheap `JsHandle #3` — repr does not cross the wire |
118
+
119
+ `eval()` is unchanged (one RTT, no extra globals). `%%js` / `eval_cell`
120
+ install cwd `require` once so cells look like Node (`require('path')`).
121
+ `--bind` is `Object.assign(globalThis, …)` — missing names and non-wire
122
+ types (`DataFrame`, a set) fail articulately. Reserved names
123
+ (`require`, `process`, `globalThis`, …) refuse so a bind cannot shadow
124
+ Node.
125
+
126
+ **Interrupt.** First Ctrl-C does not abandon the in-flight reply (that
127
+ would desync the wire and drop callbacks). The call finishes, the result
128
+ is discarded, the domain stays up. Interrupt again to **kill** the
129
+ child — cooperative `destroy()` cannot stop a JS `for (;;) {}`. Same
130
+ honesty as the rest of the bridge.
131
+
132
+ JS kernels (`tslab`, Deno Jupyter) are
133
+ [`concurrent-c-python`](https://www.npmjs.com/package/concurrent-c-python);
134
+ Colab is not that. There, default `create()` blocks the kernel thread —
135
+ `py.task` / `{ isolated: true }` when the loop must stay live.
136
+
59
137
  ## Measured
60
138
 
61
139
  [`bench_wire`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/examples/bench_wire.py)
@@ -73,6 +151,30 @@ python -m cc_node.benchmarks.multi_domain
73
151
  Wire: line-JSON on dedicated fds (stdio stays yours). Bulk spill: private
74
152
  0700 dir, 0600 files, removed with the bridge.
75
153
 
154
+ ### Vs pythonmonkey / mini-racer / DIY node
155
+
156
+ Most “JS from Python” libraries are **not Node**. Bulk is a **sum** over
157
+ 1M floats (`.length` on an in-process wrapper is free and lies). Snapshot:
158
+ [`cc_node_vs_alts_20260813.txt`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/cc_node_vs_alts_20260813.txt)
159
+ · harness: [`benchmarks/vs_alts.py`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/benchmarks/vs_alts.py).
160
+
161
+ | | cc-node | DIY JSON stdio | `node -e` each | pythonmonkey | mini-racer |
162
+ |---|---|---|---|---|---|
163
+ | identity RTT | **20µs** | 39µs | 25ms | **<1µs** | 114µs |
164
+ | callback | **36µs** | — | — | 1µs | — |
165
+ | 8MB typed / list | **6.2ms shm** / 359ms | — / 197ms | — | — / 630ms proxy | — / 78ms |
166
+ | `require('fs')` | yes | yes | yes | no | no |
167
+ | process | child `node` | child `node` | new process/call | SpiderMonkey in-process | V8 isolate |
168
+
169
+ Tiny scalars: pythonmonkey’s in-process SM beats a child. Real Node
170
+ (`require('fs')`, native addons, callbacks, stdout stays yours): this
171
+ package. Isolated `node -e` per call is ~1000× a persistent child.
172
+ Optional engines SKIP if not importable — not package deps.
173
+
174
+ The other direction (Python from Node):
175
+ [`concurrent-c-python`](https://www.npmjs.com/package/concurrent-c-python)
176
+ vs pymport / ncp / pythonia.
177
+
76
178
  ## Surface
77
179
 
78
180
  - Plain data (numbers, str, bool, `None`, lists, non-empty dicts) by
@@ -80,6 +182,8 @@ Wire: line-JSON on dedicated fds (stdio stays yours). Bulk spill: private
80
182
  `String()`). Non-finite floats are tagged.
81
183
  - Handles are per-domain. `stats()` / `release()` / idempotent
82
184
  `destroy()`; afterwards: `bridge is closed`.
185
+ - `eval_cell(src, bindings=)` is the notebook door (`%%js`): cwd
186
+ `require` once, optional `globalThis` binds; `eval()` stays one RTT.
83
187
  - Crash isolation, not a sandbox. `destroy()` is cooperative; an
84
188
  in-flight CPU-bound call finishes or you kill the child
85
189
  ([`bridge_stress.md`](https://github.com/sreekotay/concurrent-c/blob/main/stress/bridge/bridge_stress.md)).
@@ -176,7 +280,9 @@ Both bridges (npm OIDC + PyPI OIDC):
176
280
  # fallbacks: --npm-local / --pypi-twine
177
281
  ```
178
282
 
179
- Examples: `use_node`, `bench_wire`, `benchmarks.multi_domain`.
283
+ Examples: `use_node`, `bench_wire`, `benchmarks.multi_domain`,
284
+ `benchmarks.vs_alts`.
285
+ Jupyter: `%load_ext cc_node`.
180
286
  Stress: [`stress/bridge/`](https://github.com/sreekotay/concurrent-c/tree/main/stress/bridge).
181
287
  Own hot path in C/CC → native module (40–90ns) —
182
288
  [JS / Python interop](https://github.com/sreekotay/concurrent-c/blob/main/docs/js-py-modules.md).
@@ -25,9 +25,12 @@ semver.satisfies('1.2.3', '^1.0.0') # True
25
25
  js.destroy() # or: with cc_node.create() as js:
26
26
  ```
27
27
 
28
- Unlike [`concurrent-c-python`](https://github.com/sreekotay/concurrent-c/tree/main/npm/cc-python)
29
- (in-process by default), every `create()` here is a separate Node —
30
- real addons, crash isolation, measurable wire. N domains = N processes.
28
+ The other direction (Python from Node):
29
+ [`concurrent-c-python`](https://www.npmjs.com/package/concurrent-c-python)
30
+ (in-process by default; vs pymport / ncp / pythonia in that README).
31
+
32
+ Every `create()` here is a separate Node — real addons, crash isolation,
33
+ measurable wire. N domains = N processes.
31
34
 
32
35
  | | this package | CC hosted (`cc_js_new(false, …)`) |
33
36
  |---|---|---|
@@ -38,13 +41,84 @@ real addons, crash isolation, measurable wire. N domains = N processes.
38
41
  | Parallelism | N children | one process |
39
42
  | Crash | child dies; parent lives | shared fate |
40
43
 
44
+ **Cheat sheet**
45
+
46
+ - Always a child `node`. A call blocks until JS answers; thenables wait
47
+ in the child. No `{ async: true }`.
48
+ - Scalars / `None` materialize; empty `{}` stays a handle; everything
49
+ else is a `JsHandle` until `str()` / attrs / a call.
50
+ - `%load_ext cc_node` then `%%js` — one kernel domain
51
+ (`cc_node.kernel()`), not a child per cell.
52
+ - `eval()` is one RTT, no extra globals. `%%js` / `eval_cell` install
53
+ cwd `require` once.
54
+ - `--bind` is `Object.assign(globalThis, …)` of names you name (wire
55
+ types only; no pickle). Missing names refuse.
56
+ - First Ctrl-C finishes the in-flight call (wire stays in sync).
57
+ Interrupt again to kill the child.
58
+
41
59
  ```
42
- pip install concurrent-c-node # needs node on PATH
60
+ pip install concurrent-c-node # needs node on PATH
61
+ pip install 'concurrent-c-node[jupyter]' # magics (IPython)
43
62
  python -m cc_node.examples.use_node
44
63
  python -m cc_node.examples.bench_wire
45
64
  python -m cc_node.benchmarks.multi_domain
65
+ python -m cc_node.benchmarks.vs_alts # vs DIY node / pythonmonkey / mini-racer
66
+ ```
67
+
68
+ ## Jupyter / Colab
69
+
70
+ Colab and the usual Jupyter kernel are **Python** — this package. Same
71
+ calling convention as a script: a cell blocks until Node answers;
72
+ thenables wait in the child. Magics share **one** domain for the
73
+ kernel, not a spawn per cell (~28ms).
74
+
75
+ ```python
76
+ %pip install concurrent-c-node
77
+ # if `node` is missing (typical Colab):
78
+ !apt-get install -y nodejs
79
+ %load_ext cc_node
80
+
81
+ %%js
82
+ const path = require('path')
83
+ path.join('a', 'b') // last expression comes back as Python
84
+ ```
85
+
86
+ ```python
87
+ xs = [1, 2, 3, 4]
88
+
89
+ %%js -b xs -t chunks
90
+ xs.map(x => x * 2) // wire types only; no pickle fallback
46
91
  ```
47
92
 
93
+ | | |
94
+ |---|---|
95
+ | `%load_ext cc_node` | register magics; does **not** spawn until first `%%js` |
96
+ | `%js 1+1` / `%%js` | eval on `cc_node.kernel()`; last expression is the result |
97
+ | `-b xs` / `--bind xs,n` | publish those Python names on `globalThis` for the cell |
98
+ | `-t chunks` / `--to` | store the result in the notebook namespace |
99
+ | `%js_stats` | handle-table size (spawns if needed) |
100
+ | `%js_reset` | `destroy()` the kernel child; next `%%js` spawns again |
101
+ | `cc_node.kernel()` | the same domain the magics use; `create()` is still a private child |
102
+ | `JsHandle` display | cheap `JsHandle #3` — repr does not cross the wire |
103
+
104
+ `eval()` is unchanged (one RTT, no extra globals). `%%js` / `eval_cell`
105
+ install cwd `require` once so cells look like Node (`require('path')`).
106
+ `--bind` is `Object.assign(globalThis, …)` — missing names and non-wire
107
+ types (`DataFrame`, a set) fail articulately. Reserved names
108
+ (`require`, `process`, `globalThis`, …) refuse so a bind cannot shadow
109
+ Node.
110
+
111
+ **Interrupt.** First Ctrl-C does not abandon the in-flight reply (that
112
+ would desync the wire and drop callbacks). The call finishes, the result
113
+ is discarded, the domain stays up. Interrupt again to **kill** the
114
+ child — cooperative `destroy()` cannot stop a JS `for (;;) {}`. Same
115
+ honesty as the rest of the bridge.
116
+
117
+ JS kernels (`tslab`, Deno Jupyter) are
118
+ [`concurrent-c-python`](https://www.npmjs.com/package/concurrent-c-python);
119
+ Colab is not that. There, default `create()` blocks the kernel thread —
120
+ `py.task` / `{ isolated: true }` when the loop must stay live.
121
+
48
122
  ## Measured
49
123
 
50
124
  [`bench_wire`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/examples/bench_wire.py)
@@ -62,6 +136,30 @@ python -m cc_node.benchmarks.multi_domain
62
136
  Wire: line-JSON on dedicated fds (stdio stays yours). Bulk spill: private
63
137
  0700 dir, 0600 files, removed with the bridge.
64
138
 
139
+ ### Vs pythonmonkey / mini-racer / DIY node
140
+
141
+ Most “JS from Python” libraries are **not Node**. Bulk is a **sum** over
142
+ 1M floats (`.length` on an in-process wrapper is free and lies). Snapshot:
143
+ [`cc_node_vs_alts_20260813.txt`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/cc_node_vs_alts_20260813.txt)
144
+ · harness: [`benchmarks/vs_alts.py`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/benchmarks/vs_alts.py).
145
+
146
+ | | cc-node | DIY JSON stdio | `node -e` each | pythonmonkey | mini-racer |
147
+ |---|---|---|---|---|---|
148
+ | identity RTT | **20µs** | 39µs | 25ms | **<1µs** | 114µs |
149
+ | callback | **36µs** | — | — | 1µs | — |
150
+ | 8MB typed / list | **6.2ms shm** / 359ms | — / 197ms | — | — / 630ms proxy | — / 78ms |
151
+ | `require('fs')` | yes | yes | yes | no | no |
152
+ | process | child `node` | child `node` | new process/call | SpiderMonkey in-process | V8 isolate |
153
+
154
+ Tiny scalars: pythonmonkey’s in-process SM beats a child. Real Node
155
+ (`require('fs')`, native addons, callbacks, stdout stays yours): this
156
+ package. Isolated `node -e` per call is ~1000× a persistent child.
157
+ Optional engines SKIP if not importable — not package deps.
158
+
159
+ The other direction (Python from Node):
160
+ [`concurrent-c-python`](https://www.npmjs.com/package/concurrent-c-python)
161
+ vs pymport / ncp / pythonia.
162
+
65
163
  ## Surface
66
164
 
67
165
  - Plain data (numbers, str, bool, `None`, lists, non-empty dicts) by
@@ -69,6 +167,8 @@ Wire: line-JSON on dedicated fds (stdio stays yours). Bulk spill: private
69
167
  `String()`). Non-finite floats are tagged.
70
168
  - Handles are per-domain. `stats()` / `release()` / idempotent
71
169
  `destroy()`; afterwards: `bridge is closed`.
170
+ - `eval_cell(src, bindings=)` is the notebook door (`%%js`): cwd
171
+ `require` once, optional `globalThis` binds; `eval()` stays one RTT.
72
172
  - Crash isolation, not a sandbox. `destroy()` is cooperative; an
73
173
  in-flight CPU-bound call finishes or you kill the child
74
174
  ([`bridge_stress.md`](https://github.com/sreekotay/concurrent-c/blob/main/stress/bridge/bridge_stress.md)).
@@ -165,7 +265,9 @@ Both bridges (npm OIDC + PyPI OIDC):
165
265
  # fallbacks: --npm-local / --pypi-twine
166
266
  ```
167
267
 
168
- Examples: `use_node`, `bench_wire`, `benchmarks.multi_domain`.
268
+ Examples: `use_node`, `bench_wire`, `benchmarks.multi_domain`,
269
+ `benchmarks.vs_alts`.
270
+ Jupyter: `%load_ext cc_node`.
169
271
  Stress: [`stress/bridge/`](https://github.com/sreekotay/concurrent-c/tree/main/stress/bridge).
170
272
  Own hot path in C/CC → native module (40–90ns) —
171
273
  [JS / Python interop](https://github.com/sreekotay/concurrent-c/blob/main/docs/js-py-modules.md).
@@ -18,6 +18,9 @@ exceptions cross back as JS errors and vice versa, messages intact.
18
18
  Handles never cross domains; every door after destroy() answers
19
19
  articulately; destroy is idempotent and `with cc_node.create() as js:`
20
20
  scopes it.
21
+
22
+ Notebooks (Jupyter / Colab): `%load_ext cc_node` then `%%js` — one
23
+ kernel-scoped domain (`cc_node.kernel()`), same calling convention.
21
24
  """
22
25
  import array
23
26
  import atexit
@@ -25,12 +28,29 @@ import base64
25
28
  import json
26
29
  import math
27
30
  import os
31
+ import re
32
+ import select
28
33
  import shutil
29
34
  import subprocess
35
+ import sys
30
36
  import tempfile
31
37
 
32
- __all__ = ["create", "JsError", "JsHandle", "__version__"]
33
- __version__ = "0.22.0"
38
+ __all__ = [
39
+ "create", "kernel", "reset_kernel", "JsError", "JsHandle",
40
+ "load_ipython_extension", "unload_ipython_extension", "__version__",
41
+ ]
42
+ __version__ = "0.22.1"
43
+
44
+ _NO_NODE = (
45
+ "cc-node: no node executable (install Node, or set CC_NODE_BIN). "
46
+ "Colab/Jupyter: `!apt-get install -y nodejs` then retry, or pass "
47
+ "create(node=...) / CC_NODE_BIN."
48
+ )
49
+ _BIND_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
50
+ _BIND_RESERVED = frozenset({
51
+ "require", "globalThis", "global", "process", "module", "exports",
52
+ "window", "self",
53
+ })
34
54
 
35
55
  # Typed buffers cross as typed arrays; big ones spill through shared
36
56
  # memory (tmpfs where available) — one memcpy per side, receiver
@@ -97,9 +117,20 @@ class JsHandle:
97
117
  return self._d._req("str", h=self._h)
98
118
 
99
119
  def __repr__(self):
120
+ # Cheap: a wire str() is a round trip and can hang a display hook.
100
121
  return "<JsHandle #%d%s>" % (self._h,
101
122
  " (closed)" if self._d.closed else "")
102
123
 
124
+ def _repr_html_(self):
125
+ state = " closed" if self._d.closed else ""
126
+ return (
127
+ '<code title="domain-owned JS proxy; str() / attrs cross the wire">'
128
+ "JsHandle #%d%s</code>" % (self._h, state)
129
+ )
130
+
131
+ def _repr_pretty_(self, p, cycle):
132
+ p.text(repr(self))
133
+
103
134
  def __del__(self):
104
135
  # Never nest a sync wire op from GC into an in-flight _req — that
105
136
  # steals the outer reply (e.g. returning a callback arg handle).
@@ -117,6 +148,7 @@ class Bridge:
117
148
  broker = os.path.join(os.path.dirname(os.path.abspath(__file__)),
118
149
  "broker.cjs")
119
150
  node = node or os.environ.get("CC_NODE_BIN", "node")
151
+ self._node_bin = node
120
152
  # Spills live in a private 0700 per-bridge directory (predictable
121
153
  # names in a shared /dev/shm invite pre-creation races and
122
154
  # umask-dependent exposure); the child writes its spills there
@@ -143,13 +175,14 @@ class Bridge:
143
175
  for fd in (req_r, req_w, resp_r, resp_w):
144
176
  os.close(fd)
145
177
  shutil.rmtree(self._shm_dir, ignore_errors=True)
146
- raise JsError(
147
- "cc-node: no node executable (install Node, or set "
148
- "CC_NODE_BIN)") from None
178
+ raise JsError(_NO_NODE) from None
149
179
  os.close(req_r)
150
180
  os.close(resp_w)
151
181
  self._wire_w = os.fdopen(req_w, "wb")
152
- self._wire_r = os.fdopen(resp_r, "rb")
182
+ # Raw reply fd + leftover buffer: select then read, so SIGINT can
183
+ # land without mixing select() with a buffered file object.
184
+ self._resp_fd = resp_r
185
+ self._resp_buf = bytearray()
153
186
  self.closed = False
154
187
  self._nid = 1
155
188
  self._cbs = {}
@@ -160,6 +193,8 @@ class Bridge:
160
193
  self._pending_release = []
161
194
  self._close_pending = False
162
195
  self._destroy_done = False
196
+ self._cell_require_ready = False
197
+ self._cell_runner = None
163
198
  _live.append(self)
164
199
 
165
200
  # ---- wire ----
@@ -211,12 +246,84 @@ class Bridge:
211
246
  raise JsError(msg["e"])
212
247
  return self._decode_result(msg)
213
248
 
249
+ def _read_chunk(self):
250
+ fd = self._resp_fd
251
+ if fd is None:
252
+ return b""
253
+ if sys.platform == "win32":
254
+ try:
255
+ return os.read(fd, 65536)
256
+ except OSError:
257
+ return b""
258
+ while True:
259
+ try:
260
+ select.select([fd], [], [])
261
+ except InterruptedError:
262
+ continue
263
+ except OSError:
264
+ return b""
265
+ try:
266
+ return os.read(fd, 65536)
267
+ except InterruptedError:
268
+ continue
269
+ except OSError:
270
+ return b""
271
+
272
+ def _read_line(self):
273
+ """One wire line (without the LF), or b'' on EOF."""
274
+ while True:
275
+ nl = self._resp_buf.find(b"\n")
276
+ if nl >= 0:
277
+ line = bytes(self._resp_buf[:nl])
278
+ del self._resp_buf[:nl + 1]
279
+ return line
280
+ chunk = self._read_chunk()
281
+ if not chunk:
282
+ return b""
283
+ self._resp_buf.extend(chunk)
284
+
285
+ def _kill_child(self):
286
+ """Hard death from inside an in-flight wait. Cooperative close
287
+ cannot interrupt a JS CPU loop; SIGKILL of the child is the
288
+ second-interrupt door. `_req`'s finally runs `_finish_destroy`."""
289
+ self.closed = True
290
+ self._close_pending = True
291
+ try:
292
+ if self._p.poll() is None:
293
+ self._p.kill()
294
+ except Exception:
295
+ pass
296
+
297
+ def _complete_reply(self, msg, hits):
298
+ if hits:
299
+ try:
300
+ self._take_reply(msg)
301
+ except JsError:
302
+ raise
303
+ raise KeyboardInterrupt(
304
+ "cc-node: call finished after interrupt; result discarded"
305
+ ) from None
306
+ return self._take_reply(msg)
307
+
214
308
  def _wait_reply(self, rid):
215
- parked = self._parked.pop(rid, None)
216
- if parked is not None:
217
- return self._take_reply(parked)
309
+ hits = 0
218
310
  while True:
219
- line = self._wire_r.readline()
311
+ parked = self._parked.pop(rid, None)
312
+ if parked is not None:
313
+ return self._complete_reply(parked, hits)
314
+ try:
315
+ line = self._read_line()
316
+ except KeyboardInterrupt:
317
+ hits += 1
318
+ if hits == 1:
319
+ sys.stderr.write(
320
+ "cc-node: JS still running — interrupt again to "
321
+ "destroy this domain\n")
322
+ sys.stderr.flush()
323
+ continue
324
+ self._kill_child()
325
+ raise KeyboardInterrupt(
326
+ "cc-node: interrupted; domain destroyed") from None
220
327
  if not line:
221
328
  self.closed = True
222
329
  raise JsError("cc-node: the node child exited")
@@ -226,7 +333,7 @@ class Bridge:
226
333
  continue
227
334
  mid = msg.get("id")
228
335
  if mid == rid:
229
- return self._take_reply(msg)
336
+ return self._complete_reply(msg, hits)
230
337
  if mid is not None:
231
338
  # Nested _req (GC release, etc.) can overtake; park by id.
232
339
  self._parked[mid] = msg
@@ -407,6 +514,59 @@ class Bridge:
407
514
  def eval(self, src):
408
515
  return self._req("eval", src=src)
409
516
 
517
+ def eval_cell(self, src, bindings=None):
518
+ """Eval JS in this domain. Optional `bindings` are published on
519
+ `globalThis` for the call (`Object.assign`). Installs cwd
520
+ `require` once so a notebook cell can `require('path')` like
521
+ Node. `eval()` itself does not install `require` and does not
522
+ take bindings — one RTT, unchanged."""
523
+ if self.closed:
524
+ raise JsError("cc-node: bridge is closed")
525
+ self._ensure_cell_require()
526
+ if not bindings:
527
+ return self.eval(src)
528
+ names = {}
529
+ for k, v in bindings.items():
530
+ self._check_bind_name(k)
531
+ names[k] = v
532
+ runner = self._cell_runner
533
+ if runner is None:
534
+ runner = self.eval(
535
+ "(b, src) => { Object.assign(globalThis, b); "
536
+ "return (0, eval)(src); }")
537
+ self._cell_runner = runner
538
+ return runner(names, src)
539
+
540
+ def _ensure_cell_require(self):
541
+ if self._cell_require_ready or self.closed:
542
+ return
543
+ install = self.eval(
544
+ "(m) => { globalThis.require = "
545
+ "m.createRequire(process.cwd() + '/'); }")
546
+ mod = self.require("module")
547
+ try:
548
+ install(mod)
549
+ self._cell_require_ready = True
550
+ finally:
551
+ try:
552
+ self.release(mod)
553
+ except Exception:
554
+ pass
555
+ try:
556
+ self.release(install)
557
+ except Exception:
558
+ pass
559
+
560
+ @staticmethod
561
+ def _check_bind_name(name):
562
+ if not isinstance(name, str) or not _BIND_NAME.match(name):
563
+ raise JsError(
564
+ "cc-node: --bind name %r is not a JS identifier" % (name,))
565
+ if name in _BIND_RESERVED:
566
+ raise JsError(
567
+ "cc-node: --bind name %r is reserved (would shadow a "
568
+ "Node global)" % (name,))
569
+
410
570
  def release(self, handle):
411
571
  if not isinstance(handle, JsHandle) or handle._d is not self:
412
572
  raise JsError("cc-node: handle belongs to another bridge")
@@ -449,7 +609,7 @@ class Bridge:
449
609
  # land — closing the reply fd under its write is an EPIPE crash
450
610
  # in the child.
451
611
  try:
452
- while self._wire_r.readline():
612
+ while self._read_line():
453
613
  pass
454
614
  except Exception:
455
615
  pass
@@ -461,9 +621,12 @@ class Bridge:
461
621
  except Exception:
462
622
  pass
463
623
  try:
464
- self._wire_r.close()
624
+ if self._resp_fd is not None:
625
+ os.close(self._resp_fd)
465
626
  except Exception:
466
627
  pass
628
+ self._resp_fd = None
629
+ self._cell_runner = None
467
630
  shutil.rmtree(self._shm_dir, ignore_errors=True)
468
631
  if self in _live:
469
632
  _live.remove(self)
@@ -480,3 +643,48 @@ class Bridge:
480
643
 
481
644
  def create(node=None):
482
645
  return Bridge(node=node)
646
+
647
+
648
+ _kernel = None
649
+
650
+
651
+ def kernel(node=None):
652
+ """Process-wide domain for notebooks. Lazy; magics share it.
653
+ Scripts that want a private child still call `create()`."""
654
+ global _kernel
655
+ b = _kernel
656
+ if b is not None and not b.closed:
657
+ if node is not None and node != b._node_bin:
658
+ raise JsError(
659
+ "cc-node: kernel() already live with a different node; "
660
+ "reset_kernel() / %js_reset first")
661
+ return b
662
+ b = create(node=node)
663
+ try:
664
+ b._ensure_cell_require()
665
+ except Exception:
666
+ b.destroy()
667
+ raise
668
+ _kernel = b
669
+ return b
670
+
671
+
672
+ def reset_kernel():
673
+ """Destroy the kernel-scoped domain. Next `kernel()` / `%%js` spawns."""
674
+ global _kernel
675
+ b = _kernel
676
+ _kernel = None
677
+ if b is not None:
678
+ try:
679
+ b.destroy()
680
+ except Exception:
681
+ pass
682
+
683
+
684
+ def load_ipython_extension(ip):
685
+ from .magics import load_ipython_extension as _load
686
+ _load(ip)
687
+
688
+
689
+ def unload_ipython_extension(ip):
690
+ reset_kernel()