concurrent-c-node 0.7.0__tar.gz → 0.11.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: concurrent-c-node
3
- Version: 0.7.0
3
+ Version: 0.11.0
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
@@ -24,7 +24,7 @@ package bridge):
24
24
  ```python
25
25
  import cc_node
26
26
 
27
- js = cc_node.create() # an Isolation Domain: one node child
27
+ js = cc_node.create() # always a SEPARATE node process
28
28
  _ = js.require('lodash') # resolved from YOUR cwd's node_modules
29
29
  _.chunk([1, 2, 3, 4, 5], 2) # [[1, 2], [3, 4], [5]]
30
30
  _.sortBy([{'n': 3}, {'n': 1}], 'n') # dicts cross as objects, and back
@@ -35,12 +35,28 @@ semver.satisfies('1.2.3', '^1.0.0') # True
35
35
  js.destroy() # or: with cc_node.create() as js: ...
36
36
  ```
37
37
 
38
- The bridge is **pure Python, stdlib only** — no compiled code, no
39
- dependencies, nothing to build. The domain **is** a spawned `node`
40
- child (~28ms to first call), so you get real Node: full stdlib, native
41
- addons, whatever npm installs. Promise-based APIs look synchronous
42
- from Python, and bulk data crosses through **shared memory** — an 8MB
43
- array in **9ms** where the same values as a JSON list take 583ms.
38
+ ## Separate process by design
39
+
40
+ Unlike [`concurrent-c-python`](https://github.com/sreekotay/concurrent-c/tree/main/npm/cc-python)
41
+ (whose **default** embeds libpython in the Node process, with
42
+ `{ isolated: true }` as the child-process opt-in), **every**
43
+ `cc_node.create()` is already the isolated tier: one spawned `node`
44
+ child per domain. There is no in-process Node embed from Python —
45
+ you get real Node (full stdlib, native addons, whatever `npm install`
46
+ put next to your program), crash isolation, and a wire you can measure.
47
+
48
+ N domains are N OS processes: **full multi-core speedup** — fan work
49
+ across `create()` handles and they run on separate cores, no shared
50
+ event-loop or GIL between them.
51
+
52
+ | | this package — separate `node` process | Concurrent-C hosted (not this wheel) |
53
+ |---|---|---|
54
+ | API | `cc_node.create()` from Python | `cc_js_new(false, &a)` in a `.ccs` program |
55
+ | Where JS runs | own `node` child | libnode in the CC process |
56
+ | Hot call | **~105µs** wire RTT | sub-µs (needs `libnode-dev`) |
57
+ | Bulk buffers | shm spill — 8MB in **9.5ms** (52× a JSON list) | in-process |
58
+ | Parallelism | **N children = N cores — full multi-core speedup** | one process (V8's rule) |
59
+ | Crash | child dies → error; Python parent lives | shared fate with the host |
44
60
 
45
61
  ```
46
62
  pip install concurrent-c-node # needs node on PATH (or point at one)
@@ -48,9 +64,39 @@ python -m cc_node.examples.use_node
48
64
  python -m cc_node.examples.bench_wire
49
65
  ```
50
66
 
51
- Import stays `import cc_node`. Examples ship in the wheel. The mirror of
52
- [`concurrent-c-python`](https://github.com/sreekotay/concurrent-c/tree/main/npm/cc-python)
53
- same domain model, same materialization rules, pointed the other way:
67
+ ## Measured (separate-process wire)
68
+
69
+ From `python -m cc_node.examples.bench_wire` (sources under
70
+ [`cc_node/examples/`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/examples/))
71
+ on a 4-vCPU x86-64 box, node 22 / python 3.11
72
+ ([`perf/baselines/cc_node_bridge_py_20260810.txt`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/cc_node_bridge_py_20260810.txt);
73
+ catalog: [`perf/baselines/README.md`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/README.md)):
74
+
75
+ | what | result |
76
+ |---|---|
77
+ | spawn a domain (node child, first eval) | **28ms** |
78
+ | wire round trip (smallest call) | **105µs** |
79
+ | Python-callback round trip (JS → Python → JS) | **153µs** |
80
+ | 8MB `array('d')` argument, shm spill | **9.5ms** |
81
+ | the same 8MB as a JSON list | 499ms — the spill is **52x** |
82
+
83
+ The wire is strict request/response JSON on dedicated fds — replies
84
+ pair by request id, and stdio stays yours, so `console.log` in
85
+ evaluated JS reaches the real stdout and can never collide with a
86
+ protocol reply — with the shared-memory spill for bulk data (private
87
+ 0700 per-bridge directory, 0600 exclusive-create files, removed with
88
+ the bridge). The same discipline concurrent-c-python's
89
+ `{ isolated: true }` domains speak, mirrored.
90
+
91
+ One boundary, stated plainly: the domain is **crash isolation, not a
92
+ security sandbox** — the node child inherits your environment and runs
93
+ with your OS privileges, so do not run untrusted JavaScript through
94
+ it.
95
+
96
+ The bridge is **pure Python, stdlib only** — no compiled code, no
97
+ dependencies, nothing to build. Import stays `import cc_node`.
98
+ Examples ship in the wheel. Same domain model and materialization
99
+ rules as the npm sibling, pointed the other way:
54
100
 
55
101
  - **Values**: plain data (finite numbers, strings, booleans, `None`,
56
102
  lists/dicts of the same) crosses by value; everything else is a live
@@ -60,11 +106,12 @@ Import stays `import cc_node`. Examples ship in the wheel. The mirror of
60
106
  - **The domain rules hold**: handles never cross bridges; `stats()` is
61
107
  the handle ledger and `release()` drops one early; `destroy()` is
62
108
  idempotent, every door answers `bridge is closed` after, and the
63
- child dies with the bridge (and on host exit, via stdin EOF).
109
+ child dies with the bridge (and on host exit, via wire-fd EOF).
64
110
  Teardown is **cooperative** (farewell `close` + drain, then wait /
65
111
  kill-fallback): in-flight calls may still return a correct value.
66
- Hard child death (`SIGKILL`, `process.abort`) is a different contract
67
- in-flight ops must reject. See
112
+ There is no clean cancel of CPU-bound JS work wait, or kill the
113
+ child (`SIGKILL` / `process.abort`) and create a new domain. Hard death
114
+ must reject in-flight ops. See
68
115
  [`bridge_stress.md`](https://github.com/sreekotay/concurrent-c/blob/main/stress/bridge/bridge_stress.md).
69
116
 
70
117
  ## Async is free
@@ -128,9 +175,11 @@ And *which packages* it sees is the working directory's
128
175
  Run Python in your project, get your project's packages: `npm install`
129
176
  next to your program is the whole setup.
130
177
 
131
- (Writing Concurrent-C itself rather than Python? There is a zero-IPC
132
- tier: `cc_js_new(false, &a)` boots libnode *inside* your CC program see
133
- [`examples/js/jsdemo.shcc`](https://github.com/sreekotay/concurrent-c/blob/main/examples/js/jsdemo.shcc).)
178
+ Writing Concurrent-C itself rather than Python? The zero-IPC hosted
179
+ tier is `cc_js_new(false, &a)` (needs libnode)
180
+ [`examples/js/jsdemo.shcc`](https://github.com/sreekotay/concurrent-c/blob/main/examples/js/jsdemo.shcc);
181
+ `cc_js_new(true, &a)` is the same separate-process wire this package
182
+ speaks, from CC.
134
183
 
135
184
  ## Publishing
136
185
 
@@ -141,27 +190,6 @@ From the Concurrent-C repo root (packs this wheel and the npm sibling):
141
190
  ./scripts/publish_bridges.sh --publish # bump patch, pack, twine + npm publish
142
191
  ```
143
192
 
144
- ## Measured
145
-
146
- From `python -m cc_node.examples.bench_wire` (sources under
147
- [`cc_node/examples/`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/examples/))
148
- on a 4-vCPU x86-64 box, node 22 / python 3.11
149
- ([`perf/baselines/cc_node_bridge_py_20260809.txt`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/cc_node_bridge_py_20260809.txt);
150
- catalog: [`perf/baselines/README.md`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/README.md)):
151
-
152
- | what | result |
153
- |---|---|
154
- | spawn a domain (node child, first eval) | 28ms |
155
- | wire round trip (smallest call) | 116µs |
156
- | Python-callback round trip (JS → Python → JS) | 238µs |
157
- | 8MB `array('d')` argument, shm spill | **9.2ms** |
158
- | the same 8MB as a JSON list | 583ms — the spill is **63x** |
159
-
160
- The wire is strict request/response JSON over stdio with the
161
- shared-memory spill for bulk data — the same discipline concurrent-c-python's
162
- isolated domains speak, mirrored. True pinned zero-copy leases remain
163
- future work.
164
-
165
193
  A worked tour (builtin Node modules, chains, callbacks, thenables,
166
194
  buffers — no npm install needed):
167
195
  `python -m cc_node.examples.use_node`.
@@ -13,7 +13,7 @@ package bridge):
13
13
  ```python
14
14
  import cc_node
15
15
 
16
- js = cc_node.create() # an Isolation Domain: one node child
16
+ js = cc_node.create() # always a SEPARATE node process
17
17
  _ = js.require('lodash') # resolved from YOUR cwd's node_modules
18
18
  _.chunk([1, 2, 3, 4, 5], 2) # [[1, 2], [3, 4], [5]]
19
19
  _.sortBy([{'n': 3}, {'n': 1}], 'n') # dicts cross as objects, and back
@@ -24,12 +24,28 @@ semver.satisfies('1.2.3', '^1.0.0') # True
24
24
  js.destroy() # or: with cc_node.create() as js: ...
25
25
  ```
26
26
 
27
- The bridge is **pure Python, stdlib only** — no compiled code, no
28
- dependencies, nothing to build. The domain **is** a spawned `node`
29
- child (~28ms to first call), so you get real Node: full stdlib, native
30
- addons, whatever npm installs. Promise-based APIs look synchronous
31
- from Python, and bulk data crosses through **shared memory** — an 8MB
32
- array in **9ms** where the same values as a JSON list take 583ms.
27
+ ## Separate process by design
28
+
29
+ Unlike [`concurrent-c-python`](https://github.com/sreekotay/concurrent-c/tree/main/npm/cc-python)
30
+ (whose **default** embeds libpython in the Node process, with
31
+ `{ isolated: true }` as the child-process opt-in), **every**
32
+ `cc_node.create()` is already the isolated tier: one spawned `node`
33
+ child per domain. There is no in-process Node embed from Python —
34
+ you get real Node (full stdlib, native addons, whatever `npm install`
35
+ put next to your program), crash isolation, and a wire you can measure.
36
+
37
+ N domains are N OS processes: **full multi-core speedup** — fan work
38
+ across `create()` handles and they run on separate cores, no shared
39
+ event-loop or GIL between them.
40
+
41
+ | | this package — separate `node` process | Concurrent-C hosted (not this wheel) |
42
+ |---|---|---|
43
+ | API | `cc_node.create()` from Python | `cc_js_new(false, &a)` in a `.ccs` program |
44
+ | Where JS runs | own `node` child | libnode in the CC process |
45
+ | Hot call | **~105µs** wire RTT | sub-µs (needs `libnode-dev`) |
46
+ | Bulk buffers | shm spill — 8MB in **9.5ms** (52× a JSON list) | in-process |
47
+ | Parallelism | **N children = N cores — full multi-core speedup** | one process (V8's rule) |
48
+ | Crash | child dies → error; Python parent lives | shared fate with the host |
33
49
 
34
50
  ```
35
51
  pip install concurrent-c-node # needs node on PATH (or point at one)
@@ -37,9 +53,39 @@ python -m cc_node.examples.use_node
37
53
  python -m cc_node.examples.bench_wire
38
54
  ```
39
55
 
40
- Import stays `import cc_node`. Examples ship in the wheel. The mirror of
41
- [`concurrent-c-python`](https://github.com/sreekotay/concurrent-c/tree/main/npm/cc-python)
42
- same domain model, same materialization rules, pointed the other way:
56
+ ## Measured (separate-process wire)
57
+
58
+ From `python -m cc_node.examples.bench_wire` (sources under
59
+ [`cc_node/examples/`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/examples/))
60
+ on a 4-vCPU x86-64 box, node 22 / python 3.11
61
+ ([`perf/baselines/cc_node_bridge_py_20260810.txt`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/cc_node_bridge_py_20260810.txt);
62
+ catalog: [`perf/baselines/README.md`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/README.md)):
63
+
64
+ | what | result |
65
+ |---|---|
66
+ | spawn a domain (node child, first eval) | **28ms** |
67
+ | wire round trip (smallest call) | **105µs** |
68
+ | Python-callback round trip (JS → Python → JS) | **153µs** |
69
+ | 8MB `array('d')` argument, shm spill | **9.5ms** |
70
+ | the same 8MB as a JSON list | 499ms — the spill is **52x** |
71
+
72
+ The wire is strict request/response JSON on dedicated fds — replies
73
+ pair by request id, and stdio stays yours, so `console.log` in
74
+ evaluated JS reaches the real stdout and can never collide with a
75
+ protocol reply — with the shared-memory spill for bulk data (private
76
+ 0700 per-bridge directory, 0600 exclusive-create files, removed with
77
+ the bridge). The same discipline concurrent-c-python's
78
+ `{ isolated: true }` domains speak, mirrored.
79
+
80
+ One boundary, stated plainly: the domain is **crash isolation, not a
81
+ security sandbox** — the node child inherits your environment and runs
82
+ with your OS privileges, so do not run untrusted JavaScript through
83
+ it.
84
+
85
+ The bridge is **pure Python, stdlib only** — no compiled code, no
86
+ dependencies, nothing to build. Import stays `import cc_node`.
87
+ Examples ship in the wheel. Same domain model and materialization
88
+ rules as the npm sibling, pointed the other way:
43
89
 
44
90
  - **Values**: plain data (finite numbers, strings, booleans, `None`,
45
91
  lists/dicts of the same) crosses by value; everything else is a live
@@ -49,11 +95,12 @@ Import stays `import cc_node`. Examples ship in the wheel. The mirror of
49
95
  - **The domain rules hold**: handles never cross bridges; `stats()` is
50
96
  the handle ledger and `release()` drops one early; `destroy()` is
51
97
  idempotent, every door answers `bridge is closed` after, and the
52
- child dies with the bridge (and on host exit, via stdin EOF).
98
+ child dies with the bridge (and on host exit, via wire-fd EOF).
53
99
  Teardown is **cooperative** (farewell `close` + drain, then wait /
54
100
  kill-fallback): in-flight calls may still return a correct value.
55
- Hard child death (`SIGKILL`, `process.abort`) is a different contract
56
- in-flight ops must reject. See
101
+ There is no clean cancel of CPU-bound JS work wait, or kill the
102
+ child (`SIGKILL` / `process.abort`) and create a new domain. Hard death
103
+ must reject in-flight ops. See
57
104
  [`bridge_stress.md`](https://github.com/sreekotay/concurrent-c/blob/main/stress/bridge/bridge_stress.md).
58
105
 
59
106
  ## Async is free
@@ -117,9 +164,11 @@ And *which packages* it sees is the working directory's
117
164
  Run Python in your project, get your project's packages: `npm install`
118
165
  next to your program is the whole setup.
119
166
 
120
- (Writing Concurrent-C itself rather than Python? There is a zero-IPC
121
- tier: `cc_js_new(false, &a)` boots libnode *inside* your CC program see
122
- [`examples/js/jsdemo.shcc`](https://github.com/sreekotay/concurrent-c/blob/main/examples/js/jsdemo.shcc).)
167
+ Writing Concurrent-C itself rather than Python? The zero-IPC hosted
168
+ tier is `cc_js_new(false, &a)` (needs libnode)
169
+ [`examples/js/jsdemo.shcc`](https://github.com/sreekotay/concurrent-c/blob/main/examples/js/jsdemo.shcc);
170
+ `cc_js_new(true, &a)` is the same separate-process wire this package
171
+ speaks, from CC.
123
172
 
124
173
  ## Publishing
125
174
 
@@ -130,27 +179,6 @@ From the Concurrent-C repo root (packs this wheel and the npm sibling):
130
179
  ./scripts/publish_bridges.sh --publish # bump patch, pack, twine + npm publish
131
180
  ```
132
181
 
133
- ## Measured
134
-
135
- From `python -m cc_node.examples.bench_wire` (sources under
136
- [`cc_node/examples/`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/examples/))
137
- on a 4-vCPU x86-64 box, node 22 / python 3.11
138
- ([`perf/baselines/cc_node_bridge_py_20260809.txt`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/cc_node_bridge_py_20260809.txt);
139
- catalog: [`perf/baselines/README.md`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/README.md)):
140
-
141
- | what | result |
142
- |---|---|
143
- | spawn a domain (node child, first eval) | 28ms |
144
- | wire round trip (smallest call) | 116µs |
145
- | Python-callback round trip (JS → Python → JS) | 238µs |
146
- | 8MB `array('d')` argument, shm spill | **9.2ms** |
147
- | the same 8MB as a JSON list | 583ms — the spill is **63x** |
148
-
149
- The wire is strict request/response JSON over stdio with the
150
- shared-memory spill for bulk data — the same discipline concurrent-c-python's
151
- isolated domains speak, mirrored. True pinned zero-copy leases remain
152
- future work.
153
-
154
182
  A worked tour (builtin Node modules, chains, callbacks, thenables,
155
183
  buffers — no npm install needed):
156
184
  `python -m cc_node.examples.use_node`.
@@ -24,7 +24,9 @@ import base64
24
24
  import json
25
25
  import math
26
26
  import os
27
+ import shutil
27
28
  import subprocess
29
+ import tempfile
28
30
 
29
31
  __all__ = ["create", "JsError", "JsHandle", "__version__"]
30
32
  __version__ = "0.4.0"
@@ -49,20 +51,11 @@ def _numpy():
49
51
  return None
50
52
 
51
53
 
52
- def _shm_dir():
54
+ def _shm_base():
53
55
  d = os.environ.get("CC_NODE_SHM_DIR")
54
56
  if d:
55
57
  return d
56
- return "/dev/shm" if os.path.isdir("/dev/shm") else None
57
-
58
-
59
- def _shm_write(raw):
60
- _shm_seq[0] += 1
61
- path = os.path.join(_shm_dir(), "ccnode-%d-%d" % (os.getpid(),
62
- _shm_seq[0]))
63
- with open(path, "wb") as f:
64
- f.write(raw)
65
- return path
58
+ return "/dev/shm" if os.path.isdir("/dev/shm") else tempfile.gettempdir()
66
59
 
67
60
 
68
61
  class JsError(RuntimeError):
@@ -123,17 +116,39 @@ class Bridge:
123
116
  broker = os.path.join(os.path.dirname(os.path.abspath(__file__)),
124
117
  "broker.cjs")
125
118
  node = node or os.environ.get("CC_NODE_BIN", "node")
119
+ # Spills live in a private 0700 per-bridge directory (predictable
120
+ # names in a shared /dev/shm invite pre-creation races and
121
+ # umask-dependent exposure); the child writes its spills there
122
+ # too, and the directory goes with the bridge.
123
+ self._shm_dir = tempfile.mkdtemp(
124
+ prefix="ccnode-%d-" % os.getpid(), dir=_shm_base())
125
+ # The wire lives on dedicated fds; stdio is inherited, so
126
+ # console.log in evaluated JS reaches the real stdout and can
127
+ # never collide with a protocol reply. pass_fds keeps our fd
128
+ # numbers in the child, so the broker learns them from the env.
129
+ req_r, req_w = os.pipe()
130
+ resp_r, resp_w = os.pipe()
131
+ env = dict(os.environ,
132
+ CC_WIRE_IN=str(req_r), CC_WIRE_OUT=str(resp_w),
133
+ CC_NODE_SHM_DIR=self._shm_dir)
126
134
  try:
127
135
  self._p = subprocess.Popen(
128
136
  [node, broker],
129
- stdin=subprocess.PIPE,
130
- stdout=subprocess.PIPE,
137
+ pass_fds=(req_r, resp_w),
138
+ env=env,
131
139
  cwd=os.getcwd(),
132
140
  )
133
141
  except FileNotFoundError:
142
+ for fd in (req_r, req_w, resp_r, resp_w):
143
+ os.close(fd)
144
+ shutil.rmtree(self._shm_dir, ignore_errors=True)
134
145
  raise JsError(
135
146
  "cc-node: no node executable (install Node, or set "
136
147
  "CC_NODE_BIN)") from None
148
+ os.close(req_r)
149
+ os.close(resp_w)
150
+ self._wire_w = os.fdopen(req_w, "wb")
151
+ self._wire_r = os.fdopen(resp_r, "rb")
137
152
  self.closed = False
138
153
  self._nid = 1
139
154
  self._cbs = {}
@@ -150,8 +165,14 @@ class Bridge:
150
165
 
151
166
  def _send(self, obj):
152
167
  line = json.dumps(obj, allow_nan=False) + "\n"
153
- self._p.stdin.write(line.encode("utf-8"))
154
- self._p.stdin.flush()
168
+ try:
169
+ self._wire_w.write(line.encode("utf-8"))
170
+ self._wire_w.flush()
171
+ except (BrokenPipeError, ValueError, OSError):
172
+ # The child died under this write (e.g. killed from inside a
173
+ # callback). Every door answers the same way after death.
174
+ self.closed = True
175
+ raise JsError("cc-node: the node child exited") from None
155
176
 
156
177
  def _queue_release(self, hid):
157
178
  self._pending_release.append(hid)
@@ -166,6 +187,14 @@ class Bridge:
166
187
  except Exception:
167
188
  pass
168
189
 
190
+ def _shm_write(self, raw):
191
+ _shm_seq[0] += 1
192
+ path = os.path.join(self._shm_dir, "s%d" % _shm_seq[0])
193
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
194
+ with os.fdopen(fd, "wb") as f:
195
+ f.write(raw)
196
+ return path
197
+
169
198
  def _flush_shm(self):
170
199
  # The child unlinks spill files as it decodes; this sweep only
171
200
  # matters when it died first (ENOENT is the normal case).
@@ -186,7 +215,7 @@ class Bridge:
186
215
  if parked is not None:
187
216
  return self._take_reply(parked)
188
217
  while True:
189
- line = self._p.stdout.readline()
218
+ line = self._wire_r.readline()
190
219
  if not line:
191
220
  self.closed = True
192
221
  raise JsError("cc-node: the node child exited")
@@ -235,7 +264,10 @@ class Bridge:
235
264
  args = [self._decode_result(a) for a in msg["args"]]
236
265
  self._send({"cbr": msg["cbid"], "v": self._encode(fn(*args))})
237
266
  except Exception as e: # crosses back as a JS error, message intact
238
- self._send({"cbr": msg["cbid"], "e": str(e)})
267
+ try:
268
+ self._send({"cbr": msg["cbid"], "e": str(e)})
269
+ except Exception:
270
+ pass # child gone; the in-flight op surfaces EOF next read
239
271
 
240
272
  # ---- values ----
241
273
 
@@ -294,8 +326,8 @@ class Bridge:
294
326
  raw = np.ascontiguousarray(a).tobytes()
295
327
  if raw is None or kind is None:
296
328
  return None
297
- if len(raw) > _SHM_SPILL and _shm_dir():
298
- path = _shm_write(raw)
329
+ if len(raw) > _SHM_SPILL:
330
+ path = self._shm_write(raw)
299
331
  self._shm_out.append(path)
300
332
  return {"$shm": path, "t": kind}
301
333
  return {"$ta": kind, "b64": base64.b64encode(raw).decode("ascii")}
@@ -372,15 +404,22 @@ class Bridge:
372
404
  self._close_pending = False
373
405
  self.closed = True
374
406
  try:
375
- if self._p.poll() is None and self._p.stdin \
376
- and not self._p.stdin.closed:
407
+ if self._p.poll() is None and not self._wire_w.closed:
377
408
  rid = self._nid
378
409
  self._nid += 1
379
410
  self._send({"id": rid, "op": "close"})
380
411
  except Exception:
381
412
  pass
382
413
  try:
383
- self._p.stdin.close()
414
+ self._wire_w.close()
415
+ except Exception:
416
+ pass
417
+ # Drain to EOF so the broker's farewell reply has somewhere to
418
+ # land — closing the reply fd under its write is an EPIPE crash
419
+ # in the child.
420
+ try:
421
+ while self._wire_r.readline():
422
+ pass
384
423
  except Exception:
385
424
  pass
386
425
  try:
@@ -390,6 +429,11 @@ class Bridge:
390
429
  self._p.kill()
391
430
  except Exception:
392
431
  pass
432
+ try:
433
+ self._wire_r.close()
434
+ except Exception:
435
+ pass
436
+ shutil.rmtree(self._shm_dir, ignore_errors=True)
393
437
  if self in _live:
394
438
  _live.remove(self)
395
439
 
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  /* cc-node broker: the Node end of Python's JS bridge.
3
3
  *
4
- * Line-delimited JSON over stdio, strict request/response. Handles are
4
+ * Line-delimited JSON on DEDICATED wire fds, strict request/response
5
+ * stdin/stdout/stderr stay the user's, so console.log in evaluated
6
+ * code reaches the real stdout and can never collide with a protocol
7
+ * reply. Requests arrive on fd 3, replies leave on fd 4; a parent
8
+ * that cannot pin fd numbers (python's pass_fds keeps the parent's
9
+ * numbering) says which via CC_WIRE_IN / CC_WIRE_OUT. Handles are
5
10
  * integers into one table; results follow the bridge materialization
6
11
  * rule — plain data (finite numbers, strings, booleans, null, arrays
7
12
  * and plain objects of the same) crosses as a value, everything else
@@ -10,7 +15,7 @@
10
15
  * Python callable crosses as {$f: id}; invoking it sends a nested `cb`
11
16
  * request and BLOCKS on a synchronous read for the answer — legal
12
17
  * because the protocol is strictly alternating, so nothing else can be
13
- * in flight. stdin EOF is the host vanishing: exit.
18
+ * in flight. EOF on the request fd is the host vanishing: exit.
14
19
  *
15
20
  * cc/include/ccc/script/js.cch embeds this file verbatim (the CC
16
21
  * isolated tier speaks the same wire); js_iso_smoke pins the two
@@ -20,11 +25,14 @@
20
25
  const fs = require('fs');
21
26
  const { createRequire } = require('module');
22
27
 
28
+ const IN_FD = Number(process.env.CC_WIRE_IN || 3);
29
+ const OUT_FD = Number(process.env.CC_WIRE_OUT || 4);
30
+
23
31
  /* Resolve packages from the HOST's cwd — `npm install lodash` next to
24
32
  * your Python program is the point. */
25
33
  const requireCwd = createRequire(process.cwd() + '/');
26
34
 
27
- /* ---- one buffered reader over fd 0, sync and async ---- */
35
+ /* ---- one buffered reader over the request fd, sync and async ---- */
28
36
  const rbuf = { data: Buffer.alloc(0) };
29
37
 
30
38
  function takeLine() {
@@ -42,7 +50,7 @@ function readLineSync() {
42
50
  const chunk = Buffer.alloc(65536);
43
51
  let n = 0;
44
52
  try {
45
- n = fs.readSync(0, chunk, 0, chunk.length, null);
53
+ n = fs.readSync(IN_FD, chunk, 0, chunk.length, null);
46
54
  } catch (e) {
47
55
  if (e.code === 'EAGAIN') continue;
48
56
  if (e.code === 'EOF') return null;
@@ -58,7 +66,7 @@ function readLineAsync() {
58
66
  if (l !== null) return Promise.resolve(l);
59
67
  return new Promise((resolve, reject) => {
60
68
  const chunk = Buffer.alloc(65536);
61
- fs.read(0, chunk, 0, chunk.length, null, (err, n) => {
69
+ fs.read(IN_FD, chunk, 0, chunk.length, null, (err, n) => {
62
70
  if (err) return err.code === 'EOF' ? resolve(null) : reject(err);
63
71
  if (n === 0) return resolve(null);
64
72
  rbuf.data = Buffer.concat([rbuf.data, chunk.subarray(0, n)]);
@@ -68,7 +76,12 @@ function readLineAsync() {
68
76
  }
69
77
 
70
78
  function send(obj) {
71
- fs.writeSync(1, JSON.stringify(obj) + '\n');
79
+ try {
80
+ fs.writeSync(OUT_FD, JSON.stringify(obj) + '\n');
81
+ } catch (e) {
82
+ if (e.code === 'EPIPE') process.exit(0); /* host went away */
83
+ throw e;
84
+ }
72
85
  }
73
86
 
74
87
  /* ---- handles + materialization ---- */
@@ -102,7 +115,8 @@ function isPlain(v, depth) {
102
115
 
103
116
  /* Typed buffers cross as tagged bytes: small inline as base64, big
104
117
  * through the shared-memory spill (one memcpy per side; the receiver
105
- * consumes-and-unlinks). Same discipline as cc-python's wire. */
118
+ * consumes-and-unlinks; files are 0600, exclusive-create, inside the
119
+ * host's private bridge dir). Same discipline as cc-python's wire. */
106
120
  const TA_KIND = new Map([
107
121
  [Float64Array, 'f64'], [Float32Array, 'f32'],
108
122
  [Int32Array, 'i32'], [BigInt64Array, 'i64'], [Uint8Array, 'u8'],
@@ -120,8 +134,8 @@ let shmSeq = 0;
120
134
  function encodeBuffer(kind, buf) {
121
135
  if (buf.byteLength > SHM_SPILL) {
122
136
  const p = require('path').join(
123
- SHM_DIR, 'ccnode-' + process.pid + '-' + (++shmSeq));
124
- fs.writeFileSync(p, buf);
137
+ SHM_DIR, 'ccnode-c' + process.pid + '-' + (++shmSeq));
138
+ fs.writeFileSync(p, buf, { flag: 'wx', mode: 0o600 });
125
139
  return { shm: p, t: kind };
126
140
  }
127
141
  return { ta: kind, b64: buf.toString('base64') };
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: concurrent-c-node
3
- Version: 0.7.0
3
+ Version: 0.11.0
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
@@ -24,7 +24,7 @@ package bridge):
24
24
  ```python
25
25
  import cc_node
26
26
 
27
- js = cc_node.create() # an Isolation Domain: one node child
27
+ js = cc_node.create() # always a SEPARATE node process
28
28
  _ = js.require('lodash') # resolved from YOUR cwd's node_modules
29
29
  _.chunk([1, 2, 3, 4, 5], 2) # [[1, 2], [3, 4], [5]]
30
30
  _.sortBy([{'n': 3}, {'n': 1}], 'n') # dicts cross as objects, and back
@@ -35,12 +35,28 @@ semver.satisfies('1.2.3', '^1.0.0') # True
35
35
  js.destroy() # or: with cc_node.create() as js: ...
36
36
  ```
37
37
 
38
- The bridge is **pure Python, stdlib only** — no compiled code, no
39
- dependencies, nothing to build. The domain **is** a spawned `node`
40
- child (~28ms to first call), so you get real Node: full stdlib, native
41
- addons, whatever npm installs. Promise-based APIs look synchronous
42
- from Python, and bulk data crosses through **shared memory** — an 8MB
43
- array in **9ms** where the same values as a JSON list take 583ms.
38
+ ## Separate process by design
39
+
40
+ Unlike [`concurrent-c-python`](https://github.com/sreekotay/concurrent-c/tree/main/npm/cc-python)
41
+ (whose **default** embeds libpython in the Node process, with
42
+ `{ isolated: true }` as the child-process opt-in), **every**
43
+ `cc_node.create()` is already the isolated tier: one spawned `node`
44
+ child per domain. There is no in-process Node embed from Python —
45
+ you get real Node (full stdlib, native addons, whatever `npm install`
46
+ put next to your program), crash isolation, and a wire you can measure.
47
+
48
+ N domains are N OS processes: **full multi-core speedup** — fan work
49
+ across `create()` handles and they run on separate cores, no shared
50
+ event-loop or GIL between them.
51
+
52
+ | | this package — separate `node` process | Concurrent-C hosted (not this wheel) |
53
+ |---|---|---|
54
+ | API | `cc_node.create()` from Python | `cc_js_new(false, &a)` in a `.ccs` program |
55
+ | Where JS runs | own `node` child | libnode in the CC process |
56
+ | Hot call | **~105µs** wire RTT | sub-µs (needs `libnode-dev`) |
57
+ | Bulk buffers | shm spill — 8MB in **9.5ms** (52× a JSON list) | in-process |
58
+ | Parallelism | **N children = N cores — full multi-core speedup** | one process (V8's rule) |
59
+ | Crash | child dies → error; Python parent lives | shared fate with the host |
44
60
 
45
61
  ```
46
62
  pip install concurrent-c-node # needs node on PATH (or point at one)
@@ -48,9 +64,39 @@ python -m cc_node.examples.use_node
48
64
  python -m cc_node.examples.bench_wire
49
65
  ```
50
66
 
51
- Import stays `import cc_node`. Examples ship in the wheel. The mirror of
52
- [`concurrent-c-python`](https://github.com/sreekotay/concurrent-c/tree/main/npm/cc-python)
53
- same domain model, same materialization rules, pointed the other way:
67
+ ## Measured (separate-process wire)
68
+
69
+ From `python -m cc_node.examples.bench_wire` (sources under
70
+ [`cc_node/examples/`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/examples/))
71
+ on a 4-vCPU x86-64 box, node 22 / python 3.11
72
+ ([`perf/baselines/cc_node_bridge_py_20260810.txt`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/cc_node_bridge_py_20260810.txt);
73
+ catalog: [`perf/baselines/README.md`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/README.md)):
74
+
75
+ | what | result |
76
+ |---|---|
77
+ | spawn a domain (node child, first eval) | **28ms** |
78
+ | wire round trip (smallest call) | **105µs** |
79
+ | Python-callback round trip (JS → Python → JS) | **153µs** |
80
+ | 8MB `array('d')` argument, shm spill | **9.5ms** |
81
+ | the same 8MB as a JSON list | 499ms — the spill is **52x** |
82
+
83
+ The wire is strict request/response JSON on dedicated fds — replies
84
+ pair by request id, and stdio stays yours, so `console.log` in
85
+ evaluated JS reaches the real stdout and can never collide with a
86
+ protocol reply — with the shared-memory spill for bulk data (private
87
+ 0700 per-bridge directory, 0600 exclusive-create files, removed with
88
+ the bridge). The same discipline concurrent-c-python's
89
+ `{ isolated: true }` domains speak, mirrored.
90
+
91
+ One boundary, stated plainly: the domain is **crash isolation, not a
92
+ security sandbox** — the node child inherits your environment and runs
93
+ with your OS privileges, so do not run untrusted JavaScript through
94
+ it.
95
+
96
+ The bridge is **pure Python, stdlib only** — no compiled code, no
97
+ dependencies, nothing to build. Import stays `import cc_node`.
98
+ Examples ship in the wheel. Same domain model and materialization
99
+ rules as the npm sibling, pointed the other way:
54
100
 
55
101
  - **Values**: plain data (finite numbers, strings, booleans, `None`,
56
102
  lists/dicts of the same) crosses by value; everything else is a live
@@ -60,11 +106,12 @@ Import stays `import cc_node`. Examples ship in the wheel. The mirror of
60
106
  - **The domain rules hold**: handles never cross bridges; `stats()` is
61
107
  the handle ledger and `release()` drops one early; `destroy()` is
62
108
  idempotent, every door answers `bridge is closed` after, and the
63
- child dies with the bridge (and on host exit, via stdin EOF).
109
+ child dies with the bridge (and on host exit, via wire-fd EOF).
64
110
  Teardown is **cooperative** (farewell `close` + drain, then wait /
65
111
  kill-fallback): in-flight calls may still return a correct value.
66
- Hard child death (`SIGKILL`, `process.abort`) is a different contract
67
- in-flight ops must reject. See
112
+ There is no clean cancel of CPU-bound JS work wait, or kill the
113
+ child (`SIGKILL` / `process.abort`) and create a new domain. Hard death
114
+ must reject in-flight ops. See
68
115
  [`bridge_stress.md`](https://github.com/sreekotay/concurrent-c/blob/main/stress/bridge/bridge_stress.md).
69
116
 
70
117
  ## Async is free
@@ -128,9 +175,11 @@ And *which packages* it sees is the working directory's
128
175
  Run Python in your project, get your project's packages: `npm install`
129
176
  next to your program is the whole setup.
130
177
 
131
- (Writing Concurrent-C itself rather than Python? There is a zero-IPC
132
- tier: `cc_js_new(false, &a)` boots libnode *inside* your CC program see
133
- [`examples/js/jsdemo.shcc`](https://github.com/sreekotay/concurrent-c/blob/main/examples/js/jsdemo.shcc).)
178
+ Writing Concurrent-C itself rather than Python? The zero-IPC hosted
179
+ tier is `cc_js_new(false, &a)` (needs libnode)
180
+ [`examples/js/jsdemo.shcc`](https://github.com/sreekotay/concurrent-c/blob/main/examples/js/jsdemo.shcc);
181
+ `cc_js_new(true, &a)` is the same separate-process wire this package
182
+ speaks, from CC.
134
183
 
135
184
  ## Publishing
136
185
 
@@ -141,27 +190,6 @@ From the Concurrent-C repo root (packs this wheel and the npm sibling):
141
190
  ./scripts/publish_bridges.sh --publish # bump patch, pack, twine + npm publish
142
191
  ```
143
192
 
144
- ## Measured
145
-
146
- From `python -m cc_node.examples.bench_wire` (sources under
147
- [`cc_node/examples/`](https://github.com/sreekotay/concurrent-c/blob/main/pypi/cc-node/cc_node/examples/))
148
- on a 4-vCPU x86-64 box, node 22 / python 3.11
149
- ([`perf/baselines/cc_node_bridge_py_20260809.txt`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/cc_node_bridge_py_20260809.txt);
150
- catalog: [`perf/baselines/README.md`](https://github.com/sreekotay/concurrent-c/blob/main/perf/baselines/README.md)):
151
-
152
- | what | result |
153
- |---|---|
154
- | spawn a domain (node child, first eval) | 28ms |
155
- | wire round trip (smallest call) | 116µs |
156
- | Python-callback round trip (JS → Python → JS) | 238µs |
157
- | 8MB `array('d')` argument, shm spill | **9.2ms** |
158
- | the same 8MB as a JSON list | 583ms — the spill is **63x** |
159
-
160
- The wire is strict request/response JSON over stdio with the
161
- shared-memory spill for bulk data — the same discipline concurrent-c-python's
162
- isolated domains speak, mirrored. True pinned zero-copy leases remain
163
- future work.
164
-
165
193
  A worked tour (builtin Node modules, chains, callbacks, thenables,
166
194
  buffers — no npm install needed):
167
195
  `python -m cc_node.examples.use_node`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "concurrent-c-node"
7
- version = "0.7.0"
7
+ version = "0.11.0"
8
8
  description = "JavaScript and npm packages from Python over the Concurrent-C bridge: one spawned Node child per domain, host-controlled lifetime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"