@gjsify/rolldown-native 0.4.43 → 0.5.0

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 ADDED
@@ -0,0 +1,35 @@
1
+ # @gjsify/rolldown-native
2
+
3
+ A native Rust cdylib + Vala/GObject bridge that wraps the Rust `rolldown` bundler and exposes it to GJS via `gi://`. This is the default bundler engine used by `gjsify build` under GJS — npm's `rolldown` is an N-API addon that cannot load in GJS, so this bridge is how gjsify bundles without a Node runtime. Includes a complete plugin bridge (`bundleWithPlugins`) for load, transform, resolveId, and render-chunk hooks. Ships prebuilt `.so` + `.typelib` for Linux.
4
+
5
+ Part of the [gjsify](https://github.com/gjsify/gjsify) project — Node.js and Web APIs for GJS (GNOME JavaScript).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ gjsify install @gjsify/rolldown-native
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```typescript
16
+ import { hasNativeRolldown, bundle, bundleWithPlugins } from '@gjsify/rolldown-native';
17
+
18
+ if (hasNativeRolldown()) {
19
+ // Simple bundle
20
+ const result = bundle({
21
+ input: [{ import: 'src/index.ts' }],
22
+ format: 'esm',
23
+ minify: false,
24
+ });
25
+ for (const item of result.output) {
26
+ if (item.type === 'chunk') console.log(item.fileName, item.code.length, 'bytes');
27
+ }
28
+ }
29
+ ```
30
+
31
+ Under normal usage `@gjsify/rolldown-native` is consumed automatically by the gjsify CLI (`gjsify build`) — direct use is only needed when embedding the bundler in custom build tooling.
32
+
33
+ ## License
34
+
35
+ MIT
@@ -1 +1 @@
1
- import"./_virtual/_rolldown/runtime.js";import{loadNativeRolldown as e}from"./index.js";function getGLib(){let e=globalThis.imports?.gi;if(!e||!e.GLib)throw Error(`@gjsify/rolldown-native: GLib not available (not running under GJS?)`);return e.GLib}const enc=e=>getGLib().Bytes.new(new TextEncoder().encode(e)),dec=e=>new TextDecoder().decode(e.get_data()??new Uint8Array),t=[`load`,`transform`,`resolveId`,`renderChunk`,`banner`,`footer`,`intro`,`outro`,`buildStart`,`buildEnd`,`generateBundle`,`writeBundle`,`closeBundle`];function pluginMeta(e){let n=t.filter(t=>typeof e[t]==`function`),r={name:e.name,hooks:n};return e.idFilter&&(r.idFilter=e.idFilter),r}function bundleWithPlugins(t,n){let r=e();if(!r)return Promise.reject(Error(`@gjsify/rolldown-native: prebuild not available`));let i=new r.BundlerSession,a=new Map;i.connect(`context_response`,(e,t,n)=>{let r=a.get(t);if(!r)return;a.delete(t);let i;try{i=JSON.parse(dec(n))}catch(e){r.reject(e instanceof Error?e:Error(String(e)));return}i.error?r.reject(Error(i.error)):i.id?r.resolve({id:i.id,external:i.external??!1}):r.resolve(null)});function makeContext(e){return{resolve(t,n,r){return new Promise((o,s)=>{let c=i.context_resolve(e,enc(JSON.stringify({specifier:t,importer:n,skipSelf:r?.skipSelf??!0,isEntry:r?.isEntry??!1})));if(c===0){s(Error(`@gjsify/rolldown-native: ctx.resolve('${t}') failed — parent req_id ${e} unknown`));return}a.set(c,{resolve:o,reject:s})})},warn(e){i.context_warn(enc(e))},error(e){throw Error(e)}}}i.connect(`hook_requested`,(e,t,n,r,i)=>{dispatchHook(t,n,r,i)});async function dispatchHook(e,t,r,a){let o;try{o=JSON.parse(dec(a))}catch(e){respondError(t,e);return}let s=n[r];if(!s){respondError(t,Error(`@gjsify/rolldown-native: unknown plugin index ${r}`));return}let c=makeContext(t);try{if(e===`transform`&&s.transform){let e=o,n=i.take_request_payload(t);if(n===null)throw Error(`@gjsify/rolldown-native: transform request for ${e.id} missing payload bytes`);let r=dec(n);respondTransform(t,await s.transform.call(c,r,e.id,e.moduleType));return}respondOk(t,await runHook(s,e,o,c))}catch(e){respondError(t,e)}}function respondOk(e,t){let n=t==null?{kind:`skip`}:{kind:`ok`,value:t};i.respond(e,enc(JSON.stringify(n)))}function respondTransform(e,t){if(t==null){i.respond(e,enc(JSON.stringify({kind:`skip`})));return}let n,r;if(typeof t==`string`)n=t;else if(typeof t==`object`){let e=t;n=e.code,r=e.moduleType}if(typeof n!=`string`){i.respond(e,enc(JSON.stringify({kind:`skip`})));return}let a=getGLib().Bytes.new(new TextEncoder().encode(n));if(!i.set_response_payload(e,a)){respondError(e,Error(`@gjsify/rolldown-native: failed to stash transform response payload for reqId ${e}`));return}i.respond(e,enc(JSON.stringify({kind:`ok`,value:r===void 0?{hasCodeBytes:!0}:{hasCodeBytes:!0,moduleType:r}})))}function respondError(e,t){let n=t instanceof Error?t:Error(String(t));i.respond(e,enc(JSON.stringify({kind:`error`,message:n.message,stack:n.stack})))}return new Promise((e,r)=>{i.connect(`completed`,(t,n)=>{try{e(JSON.parse(dec(n)))}catch(e){r(e instanceof Error?e:Error(String(e)))}}),i.connect(`error_occurred`,(e,t)=>{r(Error(t))});try{i.start(enc(JSON.stringify({options:t,plugins:n.map(pluginMeta)})))}catch(e){r(e instanceof Error?e:Error(String(e)))}})}async function runHook(e,t,n,r){switch(t){case`load`:return e.load?normalizeLoadResult(await e.load.call(r,n.id)):null;case`transform`:return null;case`resolveId`:{if(!e.resolveId)return null;let t=n;return normalizeResolveIdResult(await e.resolveId.call(r,t.specifier,t.importer,{isEntry:t.isEntry}))}case`renderChunk`:{if(!e.renderChunk)return null;let t=n;return normalizeRenderChunkResult(await e.renderChunk.call(r,t.code,{fileName:t.fileName,name:t.name,isEntry:t.isEntry}))}case`banner`:case`footer`:case`intro`:case`outro`:{let i=e[t];if(!i)return null;let a=n;return normalizeAddonResult(await i.call(r,{fileName:a.fileName,name:a.name,isEntry:a.isEntry}))}case`buildStart`:case`buildEnd`:case`generateBundle`:case`writeBundle`:case`closeBundle`:{let n=e[t];return n&&await n.call(r),null}default:throw Error(`@gjsify/rolldown-native: unknown hook '${t}'`)}}function normalizeLoadResult(e){return e==null?null:typeof e==`string`?{code:e}:{code:e.code,moduleType:e.moduleType}}function normalizeResolveIdResult(e){return e==null?null:typeof e==`string`?{id:e}:{id:e.id,external:e.external}}function normalizeRenderChunkResult(e){return e==null?null:typeof e==`string`?{code:e}:{code:e.code}}function normalizeAddonResult(e){return e==null?null:typeof e==`string`?e:e.text}export{bundleWithPlugins};
1
+ import"./_virtual/_rolldown/runtime.js";import{loadNativeRolldown as e}from"./index.js";function getGLib(){let e=globalThis.imports?.gi;if(!e||!e.GLib)throw Error(`@gjsify/rolldown-native: GLib not available (not running under GJS?)`);return e.GLib}const enc=e=>getGLib().Bytes.new(new TextEncoder().encode(e)),dec=e=>new TextDecoder().decode(e.get_data()??new Uint8Array),t=[`load`,`transform`,`resolveId`,`renderChunk`,`banner`,`footer`,`intro`,`outro`,`buildStart`,`buildEnd`,`generateBundle`,`writeBundle`,`closeBundle`];function pluginMeta(e){let n=t.filter(t=>typeof e[t]==`function`),r={name:e.name,hooks:n};return e.idFilter&&(r.idFilter=e.idFilter),r}const n=new Set;function bundleWithPlugins(t,r){let i=e();if(!i)return Promise.reject(Error(`@gjsify/rolldown-native: prebuild not available`));let a=new i.BundlerSession;n.add(a);let o=new Map;a.connect(`context_response`,(e,t,n)=>{let r=o.get(t);if(!r)return;o.delete(t);let i;try{i=JSON.parse(dec(n))}catch(e){r.reject(e instanceof Error?e:Error(String(e)));return}i.error?r.reject(Error(i.error)):i.id?r.resolve({id:i.id,external:i.external??!1}):r.resolve(null)});function makeContext(e){return{resolve(t,n,r){return new Promise((i,s)=>{let c=a.context_resolve(e,enc(JSON.stringify({specifier:t,importer:n,skipSelf:r?.skipSelf??!0,isEntry:r?.isEntry??!1})));if(c===0){s(Error(`@gjsify/rolldown-native: ctx.resolve('${t}') failed — parent req_id ${e} unknown`));return}o.set(c,{resolve:i,reject:s})})},warn(e){a.context_warn(enc(e))},error(e){throw Error(e)}}}a.connect(`hook_requested`,(e,t,n,r,i)=>{dispatchHook(t,n,r,i)});async function dispatchHook(e,t,n,i){let o;try{o=JSON.parse(dec(i))}catch(e){respondError(t,e);return}let s=r[n];if(!s){respondError(t,Error(`@gjsify/rolldown-native: unknown plugin index ${n}`));return}let c=makeContext(t);try{if(e===`transform`&&s.transform){let e=o,n=a.take_request_payload(t);if(n===null)throw Error(`@gjsify/rolldown-native: transform request for ${e.id} missing payload bytes`);let r=dec(n);respondTransform(t,await s.transform.call(c,r,e.id,e.moduleType));return}respondOk(t,await runHook(s,e,o,c))}catch(e){respondError(t,e)}}function respondOk(e,t){let n=t==null?{kind:`skip`}:{kind:`ok`,value:t};a.respond(e,enc(JSON.stringify(n)))}function respondTransform(e,t){if(t==null){a.respond(e,enc(JSON.stringify({kind:`skip`})));return}let n,r;if(typeof t==`string`)n=t;else if(typeof t==`object`){let e=t;n=e.code,r=e.moduleType}if(typeof n!=`string`){a.respond(e,enc(JSON.stringify({kind:`skip`})));return}let i=getGLib().Bytes.new(new TextEncoder().encode(n));if(!a.set_response_payload(e,i)){respondError(e,Error(`@gjsify/rolldown-native: failed to stash transform response payload for reqId ${e}`));return}a.respond(e,enc(JSON.stringify({kind:`ok`,value:r===void 0?{hasCodeBytes:!0}:{hasCodeBytes:!0,moduleType:r}})))}function respondError(e,t){let n=t instanceof Error?t:Error(String(t));a.respond(e,enc(JSON.stringify({kind:`error`,message:n.message,stack:n.stack})))}let closeSession=()=>{n.delete(a);let e=a;try{e.close?.()}catch{}};return new Promise((e,n)=>{a.connect(`completed`,(t,r)=>{try{e(JSON.parse(dec(r)))}catch(e){n(e instanceof Error?e:Error(String(e)))}}),a.connect(`error_occurred`,(e,t)=>{n(Error(t))});try{a.start(enc(JSON.stringify({options:t,plugins:r.map(pluginMeta)})))}catch(e){n(e instanceof Error?e:Error(String(e)))}}).finally(closeSession)}async function runHook(e,t,n,r){switch(t){case`load`:return e.load?normalizeLoadResult(await e.load.call(r,n.id)):null;case`transform`:return null;case`resolveId`:{if(!e.resolveId)return null;let t=n;return normalizeResolveIdResult(await e.resolveId.call(r,t.specifier,t.importer,{isEntry:t.isEntry}))}case`renderChunk`:{if(!e.renderChunk)return null;let t=n;return normalizeRenderChunkResult(await e.renderChunk.call(r,t.code,{fileName:t.fileName,name:t.name,isEntry:t.isEntry}))}case`banner`:case`footer`:case`intro`:case`outro`:{let i=e[t];if(!i)return null;let a=n;return normalizeAddonResult(await i.call(r,{fileName:a.fileName,name:a.name,isEntry:a.isEntry}))}case`buildStart`:case`buildEnd`:case`generateBundle`:case`writeBundle`:case`closeBundle`:{let n=e[t];return n&&await n.call(r),null}default:throw Error(`@gjsify/rolldown-native: unknown hook '${t}'`)}}function normalizeLoadResult(e){return e==null?null:typeof e==`string`?{code:e}:{code:e.code,moduleType:e.moduleType}}function normalizeResolveIdResult(e){return e==null?null:typeof e==`string`?{id:e}:{id:e.id,external:e.external}}function normalizeRenderChunkResult(e){return e==null?null:typeof e==`string`?{code:e}:{code:e.code}}function normalizeAddonResult(e){return e==null?null:typeof e==`string`?e:e.text}export{bundleWithPlugins};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/rolldown-native",
3
- "version": "0.4.43",
3
+ "version": "0.5.0",
4
4
  "description": "Phase D-2 POC: Vala/GObject + Rust cdylib bridge to rolldown for GJS. Wraps `rolldown::Bundler::generate()` (async, tokio) in a per-call current-thread runtime so JS sees a sync `bundle()` call. JSON-encoded BundlerOptions in, JSON-encoded BundleOutput out — the boundary stays small even though rolldown's option surface is enormous. POC scope: no JS plugins (Phase B), no watch/HMR, no incremental builds. Companion to `@gjsify/lightningcss-native`; together they remove the last two Rust-crate runtime blockers from the gjsify build pipeline (Phase D-3 unblock).",
5
5
  "type": "module",
6
6
  "main": "lib/esm/index.js",
@@ -47,8 +47,8 @@
47
47
  "@girs/gobject-2.0": "2.88.0-4.0.4"
48
48
  },
49
49
  "devDependencies": {
50
- "@gjsify/cli": "^0.4.43",
51
- "@types/node": "^25.9.1",
50
+ "@gjsify/cli": "^0.5.0",
51
+ "@types/node": "^25.9.2",
52
52
  "typescript": "^6.0.3"
53
53
  },
54
54
  "author": "Pascal Garber <pascal@artandcode.studio>",
@@ -61,6 +61,16 @@
61
61
  </parameter>
62
62
  </parameters>
63
63
  </method>
64
+ <method name="close" c:identifier="gjsify_rolldown_bundler_session_close">
65
+ <return-value transfer-ownership="full">
66
+ <type name="none" c:type="void"/>
67
+ </return-value>
68
+ <parameters>
69
+ <instance-parameter name="self" transfer-ownership="none">
70
+ <type name="GjsifyRolldown.BundlerSession" c:type="GjsifyRolldownBundlerSession*"/>
71
+ </instance-parameter>
72
+ </parameters>
73
+ </method>
64
74
  <method name="respond" c:identifier="gjsify_rolldown_bundler_session_respond">
65
75
  <return-value transfer-ownership="full">
66
76
  <type name="none" c:type="void"/>
@@ -61,6 +61,16 @@
61
61
  </parameter>
62
62
  </parameters>
63
63
  </method>
64
+ <method name="close" c:identifier="gjsify_rolldown_bundler_session_close">
65
+ <return-value transfer-ownership="full">
66
+ <type name="none" c:type="void"/>
67
+ </return-value>
68
+ <parameters>
69
+ <instance-parameter name="self" transfer-ownership="none">
70
+ <type name="GjsifyRolldown.BundlerSession" c:type="GjsifyRolldownBundlerSession*"/>
71
+ </instance-parameter>
72
+ </parameters>
73
+ </method>
64
74
  <method name="respond" c:identifier="gjsify_rolldown_bundler_session_respond">
65
75
  <return-value transfer-ownership="full">
66
76
  <type name="none" c:type="void"/>
@@ -368,8 +368,10 @@ pub struct JsPluginProxy {
368
368
  /// the same session via Arc clone in the session constructor.
369
369
  pub next_request_id: std::sync::Arc<AtomicU64>,
370
370
  /// Wakeup pipe written after each request.send() so the Vala
371
- /// source on the main loop can react.
372
- pub request_eventfd: i32,
371
+ /// source on the main loop can react. Owned handle (see
372
+ /// `SessionShared`): keeps the fd number reserved even when this
373
+ /// proxy outlives the session's runtime shutdown.
374
+ pub request_eventfd: std::sync::Arc<std::os::fd::OwnedFd>,
373
375
  /// Maximum time we wait for a JS response before failing the
374
376
  /// build with a timeout error. Defaults to 60s.
375
377
  pub response_timeout: Duration,
@@ -467,14 +469,7 @@ impl JsPluginProxy {
467
469
  return Err(anyhow!("rolldown: plugin channel closed (session aborted)"));
468
470
  }
469
471
 
470
- let one: u64 = 1;
471
- unsafe {
472
- libc::write(
473
- self.request_eventfd,
474
- &one as *const u64 as *const libc::c_void,
475
- 8,
476
- );
477
- }
472
+ crate::session::wake_eventfd(&self.request_eventfd);
478
473
 
479
474
  let result = tokio::time::timeout(self.response_timeout, reply_rx).await;
480
475
  // Clear the context registration regardless of outcome; the
@@ -17,6 +17,7 @@
17
17
  //! eventfd, see Phase B.2 — for B.1 we expose `wait()` only).
18
18
 
19
19
  use std::ffi::CString;
20
+ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
20
21
  use std::os::raw::{c_char, c_int};
21
22
  use std::ptr;
22
23
  use std::sync::Arc;
@@ -69,7 +70,10 @@ pub struct SessionShared {
69
70
  pub contexts: Mutex<std::collections::HashMap<u64, PluginContext>>,
70
71
  pub next_child_id: AtomicU64,
71
72
  pub context_response_tx: Sender<ContextResolveResponse>,
72
- pub context_response_eventfd: c_int,
73
+ /// Owned handle — the fd stays reserved for as long as any clone
74
+ /// (incl. leaked post-shutdown tasks) is alive, so a stale write
75
+ /// can never land in a recycled fd number of a later session.
76
+ pub context_response_eventfd: Arc<OwnedFd>,
73
77
  pub context_warnings: Mutex<Vec<String>>,
74
78
  /// Phase B.4 — parallel bytes-payload slots keyed by req_id.
75
79
  /// `request_payloads` holds Rust → JS payloads (e.g. transform
@@ -93,11 +97,11 @@ pub struct BundleSession {
93
97
  /// Pending requests waiting on JS reply. Keyed by req_id.
94
98
  pub pending: Mutex<std::collections::HashMap<u64, oneshot::Sender<HookResponse>>>,
95
99
  /// eventfd for "request available" wakeup → GLib main loop watches.
96
- pub request_eventfd: c_int,
100
+ pub request_eventfd: Arc<OwnedFd>,
97
101
  /// Result of the bundle task once complete. None while still running.
98
102
  pub result: Mutex<Option<Result<String, String>>>,
99
103
  /// Completion eventfd: written exactly once when `result` is set.
100
- pub complete_eventfd: c_int,
104
+ pub complete_eventfd: Arc<OwnedFd>,
101
105
  /// Cancel flag — set by the C side via `cancel()`. tokio task
102
106
  /// observes via shared atomic and aborts.
103
107
  pub cancelled: Arc<std::sync::atomic::AtomicBool>,
@@ -167,26 +171,34 @@ pub extern "C" fn gjsify_rolldown_session_start(
167
171
  // Create the eventfd pair. EFD_NONBLOCK so writes never block;
168
172
  // EFD_SEMAPHORE off (we use counter-mode so JS can read once
169
173
  // per drain cycle and process all pending requests in a loop).
174
+ // Each fd is wrapped in Arc<OwnedFd> immediately: every holder
175
+ // (proxies, the bundle task, SessionShared, the session itself)
176
+ // keeps a clone, so the fd number stays reserved until the LAST
177
+ // user — including tasks leaked past the runtime shutdown
178
+ // timeout — has dropped it. Closing eagerly in session_free while
179
+ // such tasks still hold the raw number let their deferred
180
+ // `libc::write` land in whatever fd the kernel recycled the
181
+ // number into (issue #501's stale-fd hazard).
170
182
  let request_eventfd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
171
183
  if request_eventfd < 0 {
172
184
  unsafe { *err_out = err_to_cstr("rolldown: eventfd(request) failed".to_string()) };
173
185
  return ptr::null_mut();
174
186
  }
187
+ let request_eventfd = Arc::new(unsafe { OwnedFd::from_raw_fd(request_eventfd) });
175
188
  let complete_eventfd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
176
189
  if complete_eventfd < 0 {
177
- unsafe { libc::close(request_eventfd) };
178
190
  unsafe { *err_out = err_to_cstr("rolldown: eventfd(complete) failed".to_string()) };
179
191
  return ptr::null_mut();
180
192
  }
193
+ let complete_eventfd = Arc::new(unsafe { OwnedFd::from_raw_fd(complete_eventfd) });
181
194
 
182
195
  let context_response_eventfd =
183
196
  unsafe { libc::eventfd(0, libc::EFD_NONBLOCK | libc::EFD_CLOEXEC) };
184
197
  if context_response_eventfd < 0 {
185
- unsafe { libc::close(request_eventfd) };
186
- unsafe { libc::close(complete_eventfd) };
187
198
  unsafe { *err_out = err_to_cstr("rolldown: eventfd(context_response) failed".to_string()) };
188
199
  return ptr::null_mut();
189
200
  }
201
+ let context_response_eventfd = Arc::new(unsafe { OwnedFd::from_raw_fd(context_response_eventfd) });
190
202
 
191
203
  let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
192
204
  let (request_tx, request_rx) = unbounded::<HookRequest>();
@@ -197,7 +209,7 @@ pub extern "C" fn gjsify_rolldown_session_start(
197
209
  contexts: Mutex::new(std::collections::HashMap::new()),
198
210
  next_child_id: AtomicU64::new(1),
199
211
  context_response_tx,
200
- context_response_eventfd,
212
+ context_response_eventfd: context_response_eventfd.clone(),
201
213
  context_warnings: Mutex::new(Vec::new()),
202
214
  request_payloads: Mutex::new(std::collections::HashMap::new()),
203
215
  response_payloads: Mutex::new(std::collections::HashMap::new()),
@@ -222,7 +234,7 @@ pub extern "C" fn gjsify_rolldown_session_start(
222
234
  hooks: meta.hooks.clone(),
223
235
  request_tx: request_tx.clone(),
224
236
  next_request_id: next_request_id.clone(),
225
- request_eventfd,
237
+ request_eventfd: request_eventfd.clone(),
226
238
  response_timeout: Duration::from_secs(60),
227
239
  load_id_filter,
228
240
  transform_id_filter,
@@ -234,9 +246,6 @@ pub extern "C" fn gjsify_rolldown_session_start(
234
246
  {
235
247
  Ok(v) => v,
236
248
  Err(msg) => {
237
- unsafe { libc::close(request_eventfd) };
238
- unsafe { libc::close(complete_eventfd) };
239
- unsafe { libc::close(context_response_eventfd) };
240
249
  unsafe { *err_out = err_to_cstr(msg) };
241
250
  return ptr::null_mut();
242
251
  }
@@ -254,9 +263,6 @@ pub extern "C" fn gjsify_rolldown_session_start(
254
263
  {
255
264
  Ok(r) => r,
256
265
  Err(e) => {
257
- unsafe { libc::close(request_eventfd) };
258
- unsafe { libc::close(complete_eventfd) };
259
- unsafe { libc::close(context_response_eventfd) };
260
266
  unsafe { *err_out = err_to_cstr(format!("rolldown: tokio init: {e}")) };
261
267
  return ptr::null_mut();
262
268
  }
@@ -264,7 +270,7 @@ pub extern "C" fn gjsify_rolldown_session_start(
264
270
 
265
271
  let result_slot: Arc<Mutex<Option<Result<String, String>>>> = Arc::new(Mutex::new(None));
266
272
  let result_slot_clone = result_slot.clone();
267
- let complete_eventfd_for_task = complete_eventfd;
273
+ let complete_eventfd_for_task = complete_eventfd.clone();
268
274
  let cancelled_for_task = cancelled.clone();
269
275
  let shared_for_task = shared.clone();
270
276
 
@@ -276,14 +282,7 @@ pub extern "C" fn gjsify_rolldown_session_start(
276
282
  };
277
283
  *result_slot_clone.lock().unwrap() = Some(final_json);
278
284
  // Signal completion to the GLib main loop.
279
- let one: u64 = 1;
280
- unsafe {
281
- libc::write(
282
- complete_eventfd_for_task,
283
- &one as *const u64 as *const libc::c_void,
284
- 8,
285
- );
286
- }
285
+ wake_eventfd(&complete_eventfd_for_task);
287
286
  });
288
287
 
289
288
  let session = Box::new(BundleSession {
@@ -390,7 +389,7 @@ pub extern "C" fn gjsify_rolldown_session_request_fd(session: *mut BundleSession
390
389
  if session.is_null() {
391
390
  return -1;
392
391
  }
393
- unsafe { (*session).request_eventfd }
392
+ unsafe { (*session).request_eventfd.as_raw_fd() }
394
393
  }
395
394
 
396
395
  #[unsafe(no_mangle)]
@@ -398,7 +397,7 @@ pub extern "C" fn gjsify_rolldown_session_complete_fd(session: *mut BundleSessio
398
397
  if session.is_null() {
399
398
  return -1;
400
399
  }
401
- unsafe { (*session).complete_eventfd }
400
+ unsafe { (*session).complete_eventfd.as_raw_fd() }
402
401
  }
403
402
 
404
403
  /// Drain one pending request. Returns NULL when the channel is
@@ -541,14 +540,12 @@ pub extern "C" fn gjsify_rolldown_session_free(session: *mut BundleSession) {
541
540
  }
542
541
  unregister_result_slot(session);
543
542
  let boxed = unsafe { Box::from_raw(session) };
544
- unsafe {
545
- libc::close(boxed.request_eventfd);
546
- libc::close(boxed.complete_eventfd);
547
- libc::close(boxed.shared.context_response_eventfd);
548
- }
549
- // Dropping the runtime aborts all in-flight tasks. shutdown_timeout
550
- // gives them up to 500ms to drain; any remaining workers are
551
- // terminated. Prevents the GJS process exit from hanging.
543
+ // Shut the runtime down FIRST (up to 500ms drain; workers that
544
+ // don't finish in time are leaked), THEN let the eventfds drop.
545
+ // The fds are Arc<OwnedFd>, so even a task leaked past the
546
+ // shutdown timeout keeps its clone alive — the fd number stays
547
+ // reserved and a deferred stale write can never corrupt whatever
548
+ // fd the kernel would otherwise have recycled the number into.
552
549
  boxed.runtime.shutdown_timeout(Duration::from_millis(500));
553
550
  }
554
551
 
@@ -612,7 +609,7 @@ pub extern "C" fn gjsify_rolldown_session_context_resolve(
612
609
  error: Some(format!("rolldown: malformed context_resolve args JSON: {e}")),
613
610
  };
614
611
  let _ = session.shared.context_response_tx.send(resp);
615
- wake_eventfd(session.shared.context_response_eventfd);
612
+ wake_eventfd(&session.shared.context_response_eventfd);
616
613
  return child_id;
617
614
  }
618
615
  };
@@ -657,7 +654,7 @@ pub extern "C" fn gjsify_rolldown_session_context_resolve(
657
654
  },
658
655
  };
659
656
  let _ = shared.context_response_tx.send(resp);
660
- wake_eventfd(shared.context_response_eventfd);
657
+ wake_eventfd(&shared.context_response_eventfd);
661
658
  });
662
659
 
663
660
  child_id
@@ -693,7 +690,7 @@ pub extern "C" fn gjsify_rolldown_session_context_response_fd(
693
690
  return -1;
694
691
  }
695
692
  let session = unsafe { &*session };
696
- session.shared.context_response_eventfd
693
+ session.shared.context_response_eventfd.as_raw_fd()
697
694
  }
698
695
 
699
696
  /// FFI: drain one pending context-resolve response. Returns NULL when
@@ -723,10 +720,10 @@ pub extern "C" fn gjsify_rolldown_session_next_context_response(
723
720
  CString::new(json).ok().map(|c| c.into_raw()).unwrap_or(ptr::null_mut())
724
721
  }
725
722
 
726
- fn wake_eventfd(fd: c_int) {
723
+ pub(crate) fn wake_eventfd(fd: &OwnedFd) {
727
724
  let one: u64 = 1;
728
725
  unsafe {
729
- libc::write(fd, &one as *const u64 as *const libc::c_void, 8);
726
+ libc::write(fd.as_raw_fd(), &one as *const u64 as *const libc::c_void, 8);
730
727
  }
731
728
  }
732
729
 
@@ -175,9 +175,18 @@ gjsify_rolldown_glue_session_take_request_payload (BundleSession *session,
175
175
  if (session == NULL) return NULL;
176
176
  size_t len = 0;
177
177
  uint8_t *buf = gjsify_rolldown_session_take_request_payload (session, req_id, &len);
178
- if (buf == NULL || len == 0) return NULL;
178
+ /* NULL buf = no payload was stashed for this req_id (genuine miss) NULL.
179
+ * A non-NULL buf with len == 0 is an EMPTY-but-present payload (e.g. the
180
+ * `transform` hook on rolldown's virtual `\0rolldown/empty.js` module, whose
181
+ * code is ""). It MUST yield an empty GBytes, NOT NULL — otherwise the JS
182
+ * adapter reads `take_request_payload() === null` and throws "missing payload
183
+ * bytes", which is exactly what broke the CLI self-build. Don't fold len==0
184
+ * into the miss case. */
185
+ if (buf == NULL) return NULL;
179
186
  /* Copy into GLib heap and free the Rust allocation immediately so
180
- * GBytes refcount/lifetime stays inside the GLib side. */
187
+ * GBytes refcount/lifetime stays inside the GLib side. g_bytes_new copies
188
+ * `len` bytes; for len == 0 it never dereferences `buf`, so the Rust
189
+ * dangling-but-aligned zero-length pointer is safe here. */
181
190
  GBytes *out = g_bytes_new (buf, len);
182
191
  gjsify_rolldown_session_free_payload (buf, len);
183
192
  return out;
@@ -213,17 +213,33 @@ namespace GjsifyRolldown {
213
213
  // Watch the eventfds on the GLib main loop. add_full hands
214
214
  // the source priority + condition; we own the fd lifetime
215
215
  // via Rust, so don't pass close_fd:true.
216
+ //
217
+ // IMPORTANT: each watch source holds a strong GObject ref on
218
+ // the session (the explicit `this.ref ()` below, released 1:1
219
+ // in teardown_sources()/close() when the source is removed).
220
+ // `add_watch (cond, on_request_ready)` alone passes `this` as
221
+ // raw user_data WITHOUT a ref, so a session whose JS wrapper
222
+ // became unreachable mid-build (it is only kept alive through
223
+ // its own signal-handler closures — a self cycle) gets
224
+ // collected by the SpiderMonkey GC: dispose() removes the
225
+ // watch sources while the Rust build is still running, every
226
+ // later eventfd wake lands on an unwatched fd, and the build
227
+ // stalls forever (issue #501). The extra refs also flip GJS's
228
+ // toggle-ref into "keep the JS wrapper rooted" mode, so the
229
+ // signal closures survive until the sources are dropped.
216
230
  var req_chan = new GLib.IOChannel.unix_new (req_fd);
217
231
  req_chan.set_close_on_unref (false);
218
232
  req_chan.set_encoding (null);
219
233
  req_chan.set_buffered (false);
220
234
  _request_source_id = req_chan.add_watch (GLib.IOCondition.IN, on_request_ready);
235
+ this.ref ();
221
236
 
222
237
  var comp_chan = new GLib.IOChannel.unix_new (comp_fd);
223
238
  comp_chan.set_close_on_unref (false);
224
239
  comp_chan.set_encoding (null);
225
240
  comp_chan.set_buffered (false);
226
241
  _complete_source_id = comp_chan.add_watch (GLib.IOCondition.IN, on_complete_ready);
242
+ this.ref ();
227
243
 
228
244
  int ctx_fd = _session_context_response_fd (_handle);
229
245
  var ctx_chan = new GLib.IOChannel.unix_new (ctx_fd);
@@ -231,6 +247,25 @@ namespace GjsifyRolldown {
231
247
  ctx_chan.set_encoding (null);
232
248
  ctx_chan.set_buffered (false);
233
249
  _ctx_response_source_id = ctx_chan.add_watch (GLib.IOCondition.IN, on_ctx_response_ready);
250
+ this.ref ();
251
+ }
252
+
253
+ /**
254
+ * close — release the session's resources.
255
+ *
256
+ * Removes the three GLib eventfd watch sources and drops the owned
257
+ * Rust handle (whose `free_function`, gjsify_rolldown_session_free,
258
+ * tears down the bundler + tokio runtime + eventfds). Without this,
259
+ * every bundle leaked its session for the process lifetime — the
260
+ * watch sources hold a ref on the session, so neither the GObject
261
+ * nor the Rust side could ever be collected. Idempotent; safe to
262
+ * call before start() (no-op) and from a JS signal-handler
263
+ * continuation (the watches are removed by id, not from within
264
+ * their own dispatch).
265
+ */
266
+ public void close () {
267
+ teardown_sources ();
268
+ _handle = null;
234
269
  }
235
270
 
236
271
  private bool on_ctx_response_ready (GLib.IOChannel source, GLib.IOCondition cond) {
@@ -363,17 +398,24 @@ namespace GjsifyRolldown {
363
398
  }
364
399
 
365
400
  private void teardown_sources () {
401
+ // Each removal releases the strong ref taken in start()
402
+ // when the source was added — strictly 1:1 (the id is
403
+ // zeroed before unref, so a re-entrant call can never
404
+ // double-release).
366
405
  if (_request_source_id != 0) {
367
406
  GLib.Source.remove (_request_source_id);
368
407
  _request_source_id = 0;
408
+ this.unref ();
369
409
  }
370
410
  if (_complete_source_id != 0) {
371
411
  GLib.Source.remove (_complete_source_id);
372
412
  _complete_source_id = 0;
413
+ this.unref ();
373
414
  }
374
415
  if (_ctx_response_source_id != 0) {
375
416
  GLib.Source.remove (_ctx_response_source_id);
376
417
  _ctx_response_source_id = 0;
418
+ this.unref ();
377
419
  }
378
420
  }
379
421