@nimbus-sh/core 0.3.0 → 0.4.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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/_shared/tarball-stream.d.ts +93 -0
  4. package/dist/_shared/tarball-stream.d.ts.map +1 -0
  5. package/dist/_shared/tarball-stream.js +235 -0
  6. package/dist/_shared/tarball.d.ts +17 -0
  7. package/dist/_shared/tarball.d.ts.map +1 -0
  8. package/dist/_shared/tarball.js +39 -0
  9. package/dist/runtime/clang-runner.d.ts +38 -0
  10. package/dist/runtime/clang-runner.d.ts.map +1 -0
  11. package/dist/runtime/clang-runner.js +866 -0
  12. package/dist/runtime/facet-host.d.ts +12 -0
  13. package/dist/runtime/facet-host.d.ts.map +1 -1
  14. package/dist/runtime/local-facet-host.d.ts.map +1 -1
  15. package/dist/runtime/local-facet-host.js +29 -9
  16. package/dist/runtime/ruby-gems.d.ts +30 -0
  17. package/dist/runtime/ruby-gems.d.ts.map +1 -0
  18. package/dist/runtime/ruby-gems.js +636 -0
  19. package/dist/runtime/ruby-runner.d.ts +127 -0
  20. package/dist/runtime/ruby-runner.d.ts.map +1 -0
  21. package/dist/runtime/ruby-runner.js +1357 -0
  22. package/dist/runtime/session-process-supervisor.d.ts +15 -0
  23. package/dist/runtime/session-process-supervisor.d.ts.map +1 -1
  24. package/dist/runtime/session-process-supervisor.js +30 -0
  25. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  26. package/dist/workspace/nimbus-workspace.js +9 -2
  27. package/package.json +4 -2
  28. package/src/_shared/tarball-stream.ts +263 -0
  29. package/src/_shared/tarball.ts +46 -0
  30. package/src/runtime/clang-runner.ts +924 -0
  31. package/src/runtime/facet-host.ts +12 -0
  32. package/src/runtime/local-facet-host.ts +28 -8
  33. package/src/runtime/ruby-gems.ts +682 -0
  34. package/src/runtime/ruby-runner.ts +1484 -0
  35. package/src/runtime/session-process-supervisor.ts +27 -0
  36. package/src/workspace/nimbus-workspace.ts +10 -1
@@ -0,0 +1,1357 @@
1
+ /**
2
+ * ruby-runner.ts — ruby.wasm (Ruby 3.3.x) runner.
3
+ *
4
+ * Mirror of python-runner.ts patterns adapted to Ruby's wasi-vfs +
5
+ * canonical-abi binding. v1 scope:
6
+ * - `ruby --version` / `ruby -e '<code>'` / `ruby <file.rb>`
7
+ * - stdout/stderr → the process supervisor's log ring (Process tab integration)
8
+ * - exit code via `exit N` / unhandled exception → 1
9
+ * - argv passed through to ARGV; $PROGRAM_NAME / $0 set
10
+ * - stdlib loaded from the packed wasi-vfs inside the wasm
11
+ * - compatible pure Ruby gems through Nimbus RubyGems
12
+ * - WEBrick/Rack-style preview through Nimbus virtual sockets
13
+ *
14
+ * Out of v1:
15
+ * - native extension gems
16
+ *
17
+ * `ruby` with no args is handled by the session-level ruby-repl wrapper;
18
+ * this file owns args-bearing Ruby execution and Ruby package commands.
19
+ *
20
+ * Architecture: the interpreter runs in a FACET (runtime/facet-host.ts), and
21
+ * ruby+stdlib.wasm reaches it as a `wasmModules` entry the HOST compiles —
22
+ * because on workerd nothing else may. Per-user-VFS path:
23
+ * ~/.nimbus/runtimes/ruby/3.3.4/share/ruby/.
24
+ *
25
+ * Two things follow from that being a port rather than a Durable Object, and
26
+ * they are the whole of what is host-specific here:
27
+ * - the seed is whatever `seedFilesystem` returned — a manifest the facet
28
+ * demand-loads against where a guest can be parked mid-syscall, the bytes
29
+ * themselves where it cannot. Nothing below branches on which it got.
30
+ * - a program that keeps serving needs an actor to hold it, which is
31
+ * {@link RubyResidentStart}: supplied on Cloudflare, absent elsewhere, and
32
+ * where it is absent such a program is refused by name.
33
+ *
34
+ * - Wasm size 34.3 MiB (well under empirical 32 MiB-ish per-call
35
+ * ceiling we cleared with Pyodide + clang).
36
+ * - 35 wasi_snapshot_preview1 imports (provided by wasi-instance.ts).
37
+ * - 21 rb-js-abi-host imports (implemented for the `js` bridge used
38
+ * by the Ruby socket adapter).
39
+ * - 3 canonical_abi imports (resource lifecycle — implemented as
40
+ * a minimal Slab<number,object>).
41
+ * - Exports: _initialize, __wasi_vfs_rt_init, ruby-init,
42
+ * ruby-init-loadpath, rb-eval-string-protect, cabi_realloc,
43
+ * canonical_abi_drop_rb-abi-value, memory.
44
+ */
45
+ import { z } from 'zod';
46
+ import { hasLeadingCliFlag } from './cli-flags.js';
47
+ import { CRED_KERNEL, requireVfsCred } from './os-contracts.js';
48
+ import { WASI_INSTANCE_PREAMBLE_SRC } from './wasi-instance.js';
49
+ import { resolveVfsPath } from '../vfs/path.js';
50
+ import { RUBY_SOCKET_SHIM } from './ruby-socket-shim.js';
51
+ import { RUBY_GREEN_THREADS } from './ruby-green-threads.js';
52
+ import { defaultGemHome, installRubyBundle, installRubyGems, installedGemBins, installedGemLibRoots, parseRubyGemRequirements, } from './ruby-gems.js';
53
+ const RUBY_RUNTIME_BIN_NAMES = new Set(['ruby', 'ruby3', 'gem', 'bundle', 'bundler']);
54
+ const RUBY_VERSION_FLAGS = new Set(['--version', '-v']);
55
+ /**
56
+ * Build the ruby-runner factory. Called once at session init; the
57
+ * returned factory binds the manifest + install root for each
58
+ * registered entrypoint (`ruby`, `ruby3`).
59
+ */
60
+ export function makeRubyRunnerFactory(deps) {
61
+ const { registry } = deps;
62
+ return function rubyRunnerFactory(manifest, installRoot, binName, binKind) {
63
+ const findFile = (rel) => {
64
+ const entry = manifest.files.find((f) => f.path === rel);
65
+ return entry ? `${installRoot}/${entry.path}` : null;
66
+ };
67
+ const wasmVfs = findFile('share/ruby/ruby+stdlib.wasm');
68
+ let fsSnapshotCache = null;
69
+ const registerGemBins = (vfs) => {
70
+ if (!registry)
71
+ return;
72
+ for (const bin of installedGemBins(vfs, defaultGemHome())) {
73
+ if (RUBY_RUNTIME_BIN_NAMES.has(bin.name))
74
+ continue;
75
+ registry.register(bin.name, async (ctx) => {
76
+ const args = [bin.path.startsWith('/') ? bin.path : '/' + bin.path, ...(ctx.args ?? [])];
77
+ const ruby = typeof registry.resolve === 'function' ? await registry.resolve('ruby') : null;
78
+ if (!ruby) {
79
+ ctx.stderr.write(`${bin.name}: Ruby runtime is not registered\n`);
80
+ return 127;
81
+ }
82
+ return ruby({ ...ctx, args });
83
+ });
84
+ }
85
+ };
86
+ const rubyBinHandler = async function rubyBinHandler(ctx) {
87
+ const cred = requireVfsCred('cred' in ctx ? ctx.cred : undefined, binName);
88
+ const credKey = `${cred.uid}:${cred.gid}:${cred.groups.join(',')}`;
89
+ const vfs = deps.vfs.as(cred);
90
+ const argv = ctx.args ?? [];
91
+ const cwd = ctx.cwd || '/home/user';
92
+ const packageCommand = await maybeHandleRubyPackageCommand(binKind, binName, argv, cwd, vfs, ctx);
93
+ if (packageCommand.handled) {
94
+ if (packageCommand.exitCode === 0)
95
+ registerGemBins(vfs);
96
+ return packageCommand.exitCode;
97
+ }
98
+ const toolInvocation = buildRubyToolInvocation(binKind, binName, argv);
99
+ if (toolInvocation.error) {
100
+ ctx.stderr.write(`${binName}: ${toolInvocation.error}\n`);
101
+ return toolInvocation.exitCode;
102
+ }
103
+ // --version / --help fast paths (no wasm boot).
104
+ if (toolInvocation.mode !== 'tool' && hasLeadingCliFlag(argv, RUBY_VERSION_FLAGS)) {
105
+ ctx.stdout.write(`ruby 3.3.3 (ruby.wasm, Nimbus runtime) [wasm32-wasi]\n`);
106
+ return 0;
107
+ }
108
+ if (toolInvocation.mode !== 'tool' && (argv.includes('--help') || argv.includes('-h'))) {
109
+ ctx.stdout.write(`Usage: ${binName} [switches] [--] [programfile] [arguments]\n`);
110
+ ctx.stdout.write(`Nimbus Ruby runtime (ruby.wasm).\n`);
111
+ ctx.stdout.write(`Supported: -e <code>, <file.rb>, -r <lib>, VFS-backed require_relative and file IO\n`);
112
+ ctx.stdout.write(`WEBrick/Rack preview uses Nimbus virtual sockets; native extension gems are rejected with a precise diagnostic.\n`);
113
+ return 0;
114
+ }
115
+ // Resolve install bytes.
116
+ if (!wasmVfs || !vfs.exists(wasmVfs)) {
117
+ ctx.stderr.write(`${binName}: ruby+stdlib.wasm missing (re-run 'nimbus install ruby')\n`);
118
+ return 127;
119
+ }
120
+ const wasmBytes = vfs.readFile(wasmVfs);
121
+ // Parse argv.
122
+ const parsed = toolInvocation.mode === 'tool'
123
+ ? {
124
+ mode: 'inline',
125
+ inlineCode: toolInvocation.code,
126
+ scriptPath: '',
127
+ scriptArgs: [],
128
+ requires: [],
129
+ exitCode: 0,
130
+ }
131
+ : parseRubyArgv(argv);
132
+ if (parsed.error) {
133
+ ctx.stderr.write(`${binName}: ${parsed.error}\n`);
134
+ return parsed.exitCode;
135
+ }
136
+ // Build user program text + ARGV per mode.
137
+ let userCode = '';
138
+ let progName = binName;
139
+ let rbArgv = [binName];
140
+ if (parsed.mode === 'inline') {
141
+ userCode = parsed.inlineCode;
142
+ progName = '-e';
143
+ rbArgv = ['-e', ...parsed.scriptArgs];
144
+ }
145
+ else if (parsed.mode === 'script') {
146
+ const absPath = resolveVfsPath(parsed.scriptPath, cwd);
147
+ try {
148
+ if (!vfs.exists(absPath)) {
149
+ ctx.stderr.write(`${binName}: No such file or directory -- ${parsed.scriptPath} (LoadError)\n`);
150
+ return 1;
151
+ }
152
+ userCode = new TextDecoder('utf-8').decode(vfs.readFile(absPath));
153
+ }
154
+ catch (e) {
155
+ ctx.stderr.write(`${binName}: ${parsed.scriptPath}: ${errorMessage(e)}\n`);
156
+ return 1;
157
+ }
158
+ progName = parsed.scriptPath;
159
+ rbArgv = [parsed.scriptPath, ...parsed.scriptArgs];
160
+ }
161
+ // -r flags add prelude `require '<lib>'` lines (stdlib only).
162
+ const preludeRequires = parsed.requires.map((r) => `require ${JSON.stringify(r)}`).join('\n');
163
+ if (preludeRequires) {
164
+ userCode = preludeRequires + '\n' + userCode;
165
+ }
166
+ const userEnv = { ...(ctx.env || {}) };
167
+ if (!userEnv.HOME)
168
+ userEnv.HOME = '/home/user';
169
+ if (!userEnv.LANG)
170
+ userEnv.LANG = 'C.UTF-8';
171
+ userEnv.GEM_HOME ||= '/' + defaultGemHome();
172
+ userEnv.GEM_PATH ||= userEnv.GEM_HOME;
173
+ userEnv.NIMBUS_GEM_LIBS = installedGemLibRoots(vfs, defaultGemHome()).join(':');
174
+ // Ruby looks for charset hints via these vars; set sensible
175
+ // defaults so puts of non-ASCII strings doesn't trip on the
176
+ // wasi default of "ASCII-8BIT".
177
+ if (!userEnv.LC_ALL)
178
+ userEnv.LC_ALL = 'C.UTF-8';
179
+ // Per-subtree watermark over exactly what the snapshot covers (cwd +
180
+ // gem home), so unrelated VFS writes don't evict the cache.
181
+ const revision = Math.max(vfs.revision(cwd), vfs.revision(defaultGemHome()));
182
+ let fsSnapshot = fsSnapshotCache && fsSnapshotCache.cred === credKey
183
+ && fsSnapshotCache.cwd === cwd && fsSnapshotCache.revision === revision
184
+ ? fsSnapshotCache.result
185
+ : null;
186
+ if (!fsSnapshot) {
187
+ // The host decides what a seed IS: a manifest whose entries the facet
188
+ // demand-loads through its supervisor, or the bytes themselves. Which
189
+ // one follows from whether that host can park a guest mid-syscall, and
190
+ // nothing here depends on the answer.
191
+ fsSnapshot = deps.facets.seedFilesystem(vfs, cwd, {
192
+ extraRoots: [defaultGemHome()],
193
+ revision,
194
+ });
195
+ fsSnapshotCache = { cred: credKey, cwd, revision, result: fsSnapshot };
196
+ }
197
+ if ('error' in fsSnapshot) {
198
+ ctx.stderr.write(`${binName}: ${fsSnapshot.error}\n`);
199
+ return 1;
200
+ }
201
+ const facetArgs = {
202
+ wasmBytes,
203
+ wasmVfsPath: wasmVfs,
204
+ userCode,
205
+ rbArgv,
206
+ userEnv,
207
+ progName,
208
+ cwd,
209
+ fsSnapshot: fsSnapshot.snapshot,
210
+ };
211
+ let result;
212
+ if (needsResidentProcess(parsed)) {
213
+ if (!deps.startResident) {
214
+ ctx.stderr.write(`${binName}: this program keeps running after it starts, and this host has no `
215
+ + 'process substrate to keep it on\n');
216
+ return 1;
217
+ }
218
+ result = await deps.startResident({
219
+ wasmVfsPath: facetArgs.wasmVfsPath,
220
+ startArgs: toRubyCallArgs(facetArgs),
221
+ cwd,
222
+ command: formatRubyCommand(binName, argv),
223
+ });
224
+ }
225
+ else {
226
+ result = await dispatchRubyFacet(deps.facets, vfs, facetArgs, ctx.pid);
227
+ }
228
+ if (result.stdout)
229
+ ctx.stdout.write(result.stdout);
230
+ if (result.stderr)
231
+ ctx.stderr.write(result.stderr);
232
+ if (result.error) {
233
+ ctx.stderr.write(`${binName}: ${result.error}\n`);
234
+ return 1;
235
+ }
236
+ return result.exitCode;
237
+ };
238
+ registerGemBins(deps.vfs.as(CRED_KERNEL));
239
+ return rubyBinHandler;
240
+ };
241
+ }
242
+ async function maybeHandleRubyPackageCommand(binKind, binName, argv, cwd, vfs, ctx) {
243
+ const isGem = binKind === 'gem' || binName === 'gem';
244
+ const isBundle = binKind === 'bundle' || binName === 'bundle' || binName === 'bundler';
245
+ if (isGem && argv[0] === 'install') {
246
+ const parsed = parseGemInstallArgs(argv.slice(1));
247
+ if (parsed.error) {
248
+ ctx.stderr.write(`gem install: ${parsed.error}\n`);
249
+ return { handled: true, exitCode: 2 };
250
+ }
251
+ try {
252
+ const report = await installRubyGems(vfs, parsed.requests, { gemHome: defaultGemHome(), includeDependencies: true });
253
+ for (const name of report.installed)
254
+ ctx.stdout.write(`Successfully installed ${name}\n`);
255
+ for (const name of report.alreadyInstalled)
256
+ ctx.stdout.write(`${name} is already installed\n`);
257
+ ctx.stdout.write(`${report.installed.length + report.alreadyInstalled.length} gem(s) processed\n`);
258
+ return { handled: true, exitCode: 0 };
259
+ }
260
+ catch (e) {
261
+ ctx.stderr.write(`gem install: ${errorMessage(e)}\n`);
262
+ return { handled: true, exitCode: 1 };
263
+ }
264
+ }
265
+ if (isBundle && argv[0] === 'install') {
266
+ try {
267
+ const { requests, report, lockfilePath } = await installRubyBundle(vfs, cwd, { gemHome: defaultGemHome() });
268
+ for (const name of report.installed)
269
+ ctx.stdout.write(`Successfully installed ${name}\n`);
270
+ for (const name of report.alreadyInstalled)
271
+ ctx.stdout.write(`${name} is already installed\n`);
272
+ ctx.stdout.write(`Bundle complete! ${requests.length} Gemfile dependency(s), ${report.installed.length + report.alreadyInstalled.length} gem(s) now installed.\n`);
273
+ ctx.stdout.write(`Bundled lockfile written to /${lockfilePath}\n`);
274
+ return { handled: true, exitCode: 0 };
275
+ }
276
+ catch (e) {
277
+ ctx.stderr.write(`bundle install: ${errorMessage(e)}\n`);
278
+ return { handled: true, exitCode: 1 };
279
+ }
280
+ }
281
+ return { handled: false, exitCode: 0 };
282
+ }
283
+ function parseGemInstallArgs(argv) {
284
+ const names = [];
285
+ let versionRequirement = null;
286
+ for (let i = 0; i < argv.length; i++) {
287
+ const arg = argv[i];
288
+ if (arg === '-v' || arg === '--version') {
289
+ const version = argv[i + 1];
290
+ if (!version)
291
+ return { requests: [], error: `${arg}: missing version` };
292
+ versionRequirement = version;
293
+ i++;
294
+ continue;
295
+ }
296
+ if (arg.startsWith('--version=')) {
297
+ versionRequirement = arg.slice('--version='.length);
298
+ continue;
299
+ }
300
+ if (arg === '--no-document' || arg === '--no-doc' || arg === '--user-install') {
301
+ continue;
302
+ }
303
+ if (arg.startsWith('-')) {
304
+ return { requests: [], error: `option '${arg}' is not supported in Nimbus yet` };
305
+ }
306
+ names.push(arg);
307
+ }
308
+ const requirements = versionRequirement ? parseRubyGemRequirements(versionRequirement) : [];
309
+ const requests = names.map((name) => ({ name, requirements }));
310
+ if (requests.length === 0)
311
+ return { requests, error: 'missing gem name' };
312
+ return { requests };
313
+ }
314
+ function buildRubyToolInvocation(binKind, binName, argv) {
315
+ const isGem = binKind === 'gem' || binName === 'gem';
316
+ const isBundle = binKind === 'bundle' || binName === 'bundle' || binName === 'bundler';
317
+ if (!isGem && !isBundle)
318
+ return { mode: 'none', code: '', exitCode: 0 };
319
+ if (isGem) {
320
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
321
+ return {
322
+ mode: 'tool',
323
+ code: [
324
+ 'puts "Usage: gem --version"',
325
+ 'puts " gem env"',
326
+ 'puts " gem install <name> # installs pure Ruby gems through Nimbus RubyGems"',
327
+ ].join('\n'),
328
+ exitCode: 0,
329
+ };
330
+ }
331
+ if (argv.includes('--version') || argv[0] === '-v') {
332
+ return { mode: 'tool', code: 'require "rubygems"; puts Gem::VERSION', exitCode: 0 };
333
+ }
334
+ if (argv[0] === 'env') {
335
+ return {
336
+ mode: 'tool',
337
+ code: [
338
+ 'require "rubygems"',
339
+ 'puts "RubyGems #{Gem::VERSION}"',
340
+ 'puts "Ruby #{RUBY_VERSION} (#{RUBY_PLATFORM})"',
341
+ 'puts "GEM_HOME=#{ENV["GEM_HOME"] || File.join(ENV["HOME"], ".gem")}"',
342
+ 'puts "GEM_PATH=#{ENV["GEM_PATH"] || ENV["GEM_HOME"]}"',
343
+ ].join('\n'),
344
+ exitCode: 0,
345
+ };
346
+ }
347
+ if (argv[0] === 'install') {
348
+ return {
349
+ mode: 'none',
350
+ code: '',
351
+ error: 'gem install command was not handled by Nimbus RubyGems',
352
+ exitCode: 1,
353
+ };
354
+ }
355
+ return {
356
+ mode: 'none',
357
+ code: '',
358
+ error: `gem subcommand '${argv[0]}' is not supported yet`,
359
+ exitCode: 2,
360
+ };
361
+ }
362
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
363
+ return {
364
+ mode: 'tool',
365
+ code: [
366
+ 'puts "Usage: bundle --version"',
367
+ 'puts " bundle install # installs compatible pure Ruby gems through Nimbus RubyGems"',
368
+ ].join('\n'),
369
+ exitCode: 0,
370
+ };
371
+ }
372
+ if (argv.includes('--version') || argv[0] === '-v') {
373
+ return {
374
+ mode: 'tool',
375
+ code: [
376
+ 'begin',
377
+ ' require "bundler"',
378
+ ' puts "Bundler #{Bundler::VERSION}"',
379
+ 'rescue LoadError',
380
+ ' warn "Bundler is not bundled in this ruby.wasm runtime"',
381
+ ' exit 127',
382
+ 'end',
383
+ ].join('\n'),
384
+ exitCode: 0,
385
+ };
386
+ }
387
+ if (argv[0] === 'install') {
388
+ return {
389
+ mode: 'none',
390
+ code: '',
391
+ error: 'bundle install command was not handled by Nimbus RubyGems',
392
+ exitCode: 1,
393
+ };
394
+ }
395
+ return {
396
+ mode: 'none',
397
+ code: '',
398
+ error: `bundle subcommand '${argv[0]}' is not supported yet`,
399
+ exitCode: 2,
400
+ };
401
+ }
402
+ function parseRubyArgv(argv) {
403
+ // Ruby's CLI is rich; v1 handles -e, -r, and positional script.
404
+ const requires = [];
405
+ let i = 0;
406
+ while (i < argv.length) {
407
+ const a = argv[i];
408
+ if (a === '-e') {
409
+ const code = argv[i + 1];
410
+ if (code === undefined) {
411
+ return { mode: 'inline', inlineCode: '', scriptPath: '', scriptArgs: [],
412
+ requires, exitCode: 2, error: "no code specified for -e (RuntimeError)" };
413
+ }
414
+ // -e <code> [args...] — code into program, rest into ARGV.
415
+ // Note: Ruby allows multiple -e; concatenated with \n.
416
+ let concat = code;
417
+ let j = i + 2;
418
+ while (j < argv.length && argv[j] === '-e') {
419
+ const more = argv[j + 1];
420
+ if (more === undefined) {
421
+ return { mode: 'inline', inlineCode: '', scriptPath: '', scriptArgs: [],
422
+ requires, exitCode: 2, error: "no code specified for -e (RuntimeError)" };
423
+ }
424
+ concat = concat + '\n' + more;
425
+ j += 2;
426
+ }
427
+ return {
428
+ mode: 'inline',
429
+ inlineCode: concat,
430
+ scriptPath: '',
431
+ scriptArgs: argv.slice(j),
432
+ requires,
433
+ exitCode: 0,
434
+ };
435
+ }
436
+ if (a === '-r') {
437
+ const lib = argv[i + 1];
438
+ if (lib === undefined) {
439
+ return { mode: 'inline', inlineCode: '', scriptPath: '', scriptArgs: [],
440
+ requires, exitCode: 2, error: "missing argument for -r" };
441
+ }
442
+ requires.push(lib);
443
+ i += 2;
444
+ continue;
445
+ }
446
+ if (a.startsWith('-r') && a.length > 2) {
447
+ // -rjson form (no space).
448
+ requires.push(a.slice(2));
449
+ i++;
450
+ continue;
451
+ }
452
+ if (!a.startsWith('-')) {
453
+ return {
454
+ mode: 'script',
455
+ inlineCode: '',
456
+ scriptPath: a,
457
+ scriptArgs: argv.slice(i + 1),
458
+ requires,
459
+ exitCode: 0,
460
+ };
461
+ }
462
+ // Unknown flag — v1 silently ignores common harmless ones, errors on others.
463
+ if (/^-[wWdEKUI]+$/.test(a)) {
464
+ i++;
465
+ continue;
466
+ }
467
+ if (a === '--disable-gems' || a === '--enable-gems') {
468
+ i++;
469
+ continue;
470
+ }
471
+ return { mode: 'inline', inlineCode: '', scriptPath: '', scriptArgs: [],
472
+ requires, exitCode: 2, error: `invalid option: ${a}` };
473
+ }
474
+ return { mode: 'inline', inlineCode: '', scriptPath: '', scriptArgs: [],
475
+ requires, exitCode: 2, error: "REPL not supported in v1. Use 'ruby -e \"code\"' or 'ruby script.rb'." };
476
+ }
477
+ /**
478
+ * Which process shape this invocation needs - and NOTHING else. Both shapes
479
+ * run the same language: threads, queues and the socket classes come from the
480
+ * VM preamble, so what is decided here is how long the process lives, not what
481
+ * Ruby the program gets.
482
+ *
483
+ * A script is a program and gets a process that can outlive the command. A
484
+ * one-liner is an expression and is answered from the pooled VM, which is an
485
+ * order of magnitude faster (measured: 101ms against 1355ms) and cannot hold a
486
+ * port open afterwards. The requires listed here are the ways a one-liner asks
487
+ * for a server anyway - `ruby -run -e httpd` is the built-in one. When the
488
+ * guess is wrong the program still gets a straight answer, because binding a
489
+ * port without a process to hold it says exactly that.
490
+ */
491
+ function needsResidentProcess(parsed) {
492
+ if (parsed.mode === 'script')
493
+ return true;
494
+ return parsed.requires.some((name) => {
495
+ const root = name.split('/', 1)[0];
496
+ return root === 'socket' || root === 'webrick' || root === 'rackup' || root === 'un';
497
+ });
498
+ }
499
+ function formatRubyCommand(binName, argv) {
500
+ return [binName, ...argv].map((part) => {
501
+ if (/^[A-Za-z0-9_./:=@+-]+$/.test(part))
502
+ return part;
503
+ return JSON.stringify(part);
504
+ }).join(' ');
505
+ }
506
+ function errorMessage(error) {
507
+ return error instanceof Error ? error.message : String(error);
508
+ }
509
+ function toArrayBuffer(bytes) {
510
+ const out = new ArrayBuffer(bytes.byteLength);
511
+ new Uint8Array(out).set(bytes);
512
+ return out;
513
+ }
514
+ const RubyFacetResultSchema = z.object({
515
+ exitCode: z.number().optional(),
516
+ stdout: z.string().optional(),
517
+ stderr: z.string().optional(),
518
+ error: z.string().optional(),
519
+ }).passthrough();
520
+ /**
521
+ * A facet's answer, checked at the trust boundary. Exported for the resident
522
+ * substrate, whose boot payload carries the same object from the same VM.
523
+ */
524
+ export function normalizeRubyFacetResult(raw) {
525
+ const parsed = RubyFacetResultSchema.safeParse(raw);
526
+ if (!parsed.success)
527
+ return null;
528
+ return {
529
+ exitCode: Number(parsed.data.exitCode || 0),
530
+ stdout: parsed.data.stdout || '',
531
+ stderr: parsed.data.stderr || '',
532
+ error: parsed.data.error,
533
+ };
534
+ }
535
+ /** The per-call payload, from the invocation's own state. */
536
+ function toRubyCallArgs(args) {
537
+ return {
538
+ userCode: args.userCode,
539
+ rbArgv: args.rbArgv,
540
+ userEnv: args.userEnv,
541
+ progName: args.progName,
542
+ cwd: args.cwd,
543
+ fsSnapshot: args.fsSnapshot,
544
+ };
545
+ }
546
+ async function dispatchRubyFacet(facets, vfs, args, pid) {
547
+ // The Ruby preamble runs the entire bootstrap in the facet's own scope,
548
+ // before any function is submitted: the wasm Module is instantiated where
549
+ // the host permits it, _initialize + __wasi_vfs_rt_init run, and the live
550
+ // instance stays in that scope for every later call.
551
+ //
552
+ // The preamble also includes WASI_INSTANCE_PREAMBLE_SRC so
553
+ // __wasiMakeImports / __wasiInitFS / __wasiRunStart are in scope.
554
+ const facet = facets.open({
555
+ tag: 'ruby-runner',
556
+ concurrency: 1,
557
+ // Never absent. The supervisor derives the write credential from the pid,
558
+ // so a facet given the capability without one has a filesystem it can read
559
+ // and never write — every write-back rejected as an unauthorized process.
560
+ syscalls: { vfs, pid },
561
+ preamble: buildRubyPreamble(),
562
+ });
563
+ const facetFn = async function rubyFacetCall(inArgs, facetEnv) {
564
+ const fn = Reflect.get(globalThis, '__rubyRun');
565
+ if (typeof fn !== 'function') {
566
+ return { exitCode: 127, stdout: '', stderr: '',
567
+ error: 'ruby-runner preamble missing: __rubyRun not in scope' };
568
+ }
569
+ const adopt = Reflect.get(globalThis, '__wasiAdoptSupervisor');
570
+ const drain = Reflect.get(globalThis, '__wasiDrainPersist');
571
+ const supervisor = facetEnv && facetEnv.SUPERVISOR;
572
+ // Published where __rubyRun re-adopts it after the mount; adopting only
573
+ // here would be undone by __wasiInitFS.
574
+ if (supervisor)
575
+ Reflect.set(globalThis, '__nimbusRubySupervisor', supervisor);
576
+ adopt?.(supervisor);
577
+ try {
578
+ return await fn({
579
+ userCode: inArgs.userCode,
580
+ rbArgv: inArgs.rbArgv,
581
+ userEnv: inArgs.userEnv,
582
+ progName: inArgs.progName,
583
+ cwd: inArgs.cwd,
584
+ fsSnapshot: inArgs.fsSnapshot,
585
+ });
586
+ }
587
+ finally {
588
+ // Even on a raised Ruby exception the writes that already happened are
589
+ // the user's data, so the drain is in `finally`, not the success path.
590
+ await drain?.();
591
+ }
592
+ };
593
+ try {
594
+ const rawResult = await facet.submit(facetFn, toRubyCallArgs(args), {
595
+ wasmModules: {
596
+ 'ruby+stdlib.wasm': toArrayBuffer(args.wasmBytes),
597
+ },
598
+ timeoutMs: 300_000,
599
+ });
600
+ return normalizeRubyFacetResult(rawResult) || {
601
+ exitCode: 1,
602
+ stdout: '',
603
+ stderr: '',
604
+ error: 'ruby-runner dispatch returned an invalid payload',
605
+ };
606
+ }
607
+ catch (e) {
608
+ return {
609
+ exitCode: 1,
610
+ stdout: '',
611
+ stderr: '',
612
+ error: `ruby-runner dispatch failed: ${errorMessage(e)}`,
613
+ };
614
+ }
615
+ finally {
616
+ facet.dispose();
617
+ }
618
+ }
619
+ /**
620
+ * Compose the facet preamble. It is evaluated once in the facet's scope,
621
+ * instantiates ruby+stdlib.wasm from the module the host compiled, and
622
+ * bootstraps the Ruby VM. Per-call __rubyRun then drives
623
+ * `rb-eval-string-protect` for each invocation.
624
+ *
625
+ * Exported because the resident-process substrate composes the same source
626
+ * into its own worker module: a server and a `ruby -e` one-liner are the same
627
+ * language, and a second hand-rolled copy of this is how ruby-repl once booted
628
+ * a VM whose language prelude was missing.
629
+ */
630
+ export function buildRubyPreamble() {
631
+ return [
632
+ '// ── WASI shim preamble (wasi-instance.ts) ─────────────────────',
633
+ WASI_INSTANCE_PREAMBLE_SRC,
634
+ '',
635
+ '// ── Ruby language prelude ─────────────────────────────────────',
636
+ '// Green threads and the socket classes, evaluated once with the rest of',
637
+ '// VM startup. It lives in the shared preamble so BOTH process shapes get',
638
+ '// it from the same place: a resident server and a one-shot `ruby -e` are',
639
+ '// the same language, and only differ in how long the process lives.',
640
+ `const RUBY_LANGUAGE_PRELUDE = ${JSON.stringify(`${RUBY_GREEN_THREADS}\n${RUBY_SOCKET_SHIM}`)};`,
641
+ '',
642
+ '// ── FinalizationRegistry shim ─────────────────────────────────',
643
+ '// Ruby ABI guest uses FinalizationRegistry for resource cleanup.',
644
+ '// workerd does not always expose it (compat-flag gated). Same',
645
+ '// no-op pattern as python-runner v2 — leaky but acceptable for',
646
+ '// per-call facet lifetime (each invocation spawns a fresh facet).',
647
+ 'if (typeof globalThis.FinalizationRegistry === "undefined") {',
648
+ ' globalThis.FinalizationRegistry = class FinalizationRegistry {',
649
+ ' constructor(_cleanup) {}',
650
+ ' register(_target, _heldValue, _token) {}',
651
+ ' unregister(_token) {}',
652
+ ' };',
653
+ '}',
654
+ '',
655
+ RUBY_RUNNER_PREAMBLE_TAIL,
656
+ ].join('\n');
657
+ }
658
+ /**
659
+ * The Ruby-specific portion of the preamble. Wires the wasm imports
660
+ * (wasi_snapshot_preview1 from __wasiMakeImports, canonical_abi from a
661
+ * tiny Slab implementation, rb-js-abi-host for the `js` bridge),
662
+ * instantiates the wasm Module from __NIMBUS_WASM at module-init, and
663
+ * runs Ruby's bootstrap sequence.
664
+ *
665
+ * Per-call __rubyRun then mutates WASI argv/env, clears the stdout/
666
+ * stderr capture buffers, and invokes rb-eval-string-protect with a
667
+ * wrapper that captures SystemExit to extract the exit code.
668
+ */
669
+ export const RUBY_RUNNER_PREAMBLE_TAIL = `
670
+ // ── BEGIN: ruby-runner preamble (Ruby 3.3.4, Nimbus v1) ─────────────
671
+
672
+ // Capture buffers shared across the bootstrap and per-call paths. The
673
+ // preamble's WASI imports route fd_write stdout/stderr into these via
674
+ // __wasiMakeImports({stdoutWrite, stderrWrite}). Per-call __rubyRun
675
+ // slices from these to isolate output per invocation.
676
+ globalThis.__nimbusRubyStdout = globalThis.__nimbusRubyStdout || [];
677
+ globalThis.__nimbusRubyStderr = globalThis.__nimbusRubyStderr || [];
678
+
679
+ // Whether this facet can suspend the VM mid-syscall, asked of the engine
680
+ // rather than passed in: the answer is a property of where this scope was
681
+ // built, and the scope is the only thing that knows.
682
+ const __nimbusRubyParking = typeof WebAssembly.promising === 'function' ? 'jspi' : 'none';
683
+
684
+ function __nimbusInstallRubyFsSnapshot(snapshot) {
685
+ const dirs = new Set(['tmp', 'home']);
686
+ const files = {};
687
+ // Null-safe like every other field here: a REPL eval calls __rubyRun with
688
+ // no snapshot at all and must get the bootstrap defaults, not a TypeError.
689
+ const modes = { '': 7, tmp: 7, home: 7, ...(snapshot && snapshot.modes) };
690
+ for (const dir of (snapshot && snapshot.dirs) || []) dirs.add(String(dir).replace(/^\\/+/, '').replace(/\\/+$/, ''));
691
+ for (const [path, b64] of Object.entries((snapshot && snapshot.files) || {})) {
692
+ files[String(path).replace(/^\\/+/, '')] = b64;
693
+ }
694
+ // Metadata-only entries: the manifest carries each file's size and content
695
+ // arrives on first read. Canonicalized exactly like the content entries.
696
+ const sizes = {};
697
+ for (const [path, size] of Object.entries((snapshot && snapshot.sizes) || {})) {
698
+ sizes[String(path).replace(/^\\/+/, '')] = size;
699
+ }
700
+ __wasiInitFS({
701
+ root: '',
702
+ preopens: [
703
+ { wasiPath: '/', vfsPath: '' },
704
+ { wasiPath: '/tmp', vfsPath: 'tmp' },
705
+ { wasiPath: '/home', vfsPath: 'home' },
706
+ ],
707
+ files,
708
+ sizes,
709
+ dirs: Array.from(dirs).filter(Boolean),
710
+ modes,
711
+ // Forwarded, never invented here: only the producer knows whether it
712
+ // walked those roots completely.
713
+ enumeratedRoots: (snapshot && snapshot.enumeratedRoots) || [],
714
+ revision: snapshot && snapshot.revision,
715
+ });
716
+ }
717
+
718
+ // ── Canonical-ABI resource Slab ────────────────────────────────────
719
+ // Pyodide-style minimal resource manager. Ruby's rb-abi-guest.js uses
720
+ // these 4 functions for resource_drop / resource_new / resource_get /
721
+ // resource_clone, but the wasm itself only imports 3:
722
+ // resource_drop_js-abi-value, resource_new_rb-abi-value, resource_get_rb-abi-value
723
+ class __NimbusRubySlab {
724
+ constructor() { this._map = new Map(); this._next = 1; }
725
+ insert(obj) { const id = this._next++; this._map.set(id, obj); return id; }
726
+ get(id) { return this._map.get(id); }
727
+ remove(id) { const v = this._map.get(id); this._map.delete(id); return v; }
728
+ }
729
+
730
+ // ── Bootstrap promise: runs at child-facet module-init time ────────
731
+ //
732
+ // Mirrors pyodide v2's __pyodideBootstrap pattern. The synchronous
733
+ // portion (WebAssembly.instantiate + _initialize + ruby-init-loadpath
734
+ // + ruby-init) all completes before the first await — so it executes
735
+ // in module-init CSP context where workerd permits wasm code-gen
736
+ // from the LOADER-provided Module.
737
+ globalThis.__rubyBootstrap = (async function nimbusRubyBootstrap() {
738
+ const wasmTable = globalThis.__NIMBUS_WASM || {};
739
+ const rubyMod = wasmTable['ruby+stdlib.wasm'];
740
+ if (!rubyMod) {
741
+ return { ok: false, error: '__NIMBUS_WASM missing ruby+stdlib.wasm' };
742
+ }
743
+
744
+ // WASI init — empty preopens initially. Per-call __rubyRun can mount
745
+ // a cwd preopen if needed (for ruby <file.rb> reading via WASI).
746
+ // For v1 (-e mode) we just need stdout/stderr capture + a minimal
747
+ // FS so Ruby's stdlib init (which probes /tmp + $HOME) doesn't crash.
748
+ __wasiInitFS({
749
+ root: '',
750
+ preopens: [
751
+ // Preopen / so Ruby can resolve all FS paths through WASI.
752
+ // Ruby's __wasi_vfs_rt_init mounts its packed stdlib under /usr
753
+ // inside the wasm's internal VFS — these preopens are for the
754
+ // OUTER (host-visible) FS that wasi_snapshot_preview1 exposes.
755
+ { wasiPath: '/', vfsPath: '' },
756
+ { wasiPath: '/tmp', vfsPath: 'tmp' },
757
+ { wasiPath: '/home', vfsPath: 'home' },
758
+ ],
759
+ files: {},
760
+ dirs: ['tmp', 'home'],
761
+ modes: { '': 7, tmp: 7, home: 7 },
762
+ });
763
+
764
+ // Initial argv/env (bootstrap defaults). Per-call __rubyRun re-
765
+ // initializes WASI with the actual user argv/env before evaluating
766
+ // user code.
767
+ let memRef = null;
768
+ const wasi = __wasiMakeImports({
769
+ argv: ['ruby'],
770
+ env: { HOME: '/home/ruby', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8' },
771
+ // Stated, not defaulted. A host that cannot park a guest hands over the
772
+ // whole filesystem instead of a manifest, so nothing here needs to block —
773
+ // and saying so is what makes a syscall that blocks anyway fail loudly
774
+ // instead of returning a Promise where the guest expects an errno.
775
+ parking: __nimbusRubyParking,
776
+ getMemory: () => memRef,
777
+ stdoutWrite: (s) => { globalThis.__nimbusRubyStdout.push(s); },
778
+ stderrWrite: (s) => { globalThis.__nimbusRubyStderr.push(s); },
779
+ });
780
+
781
+ // canonical_abi imports — 3 resource lifecycle fns. The Slab is
782
+ // shared across the lifetime of the facet (single call, then the
783
+ // facet is reaped).
784
+ const rbValueSlab = new __NimbusRubySlab();
785
+ const jsValueSlab = new __NimbusRubySlab();
786
+ const canonical_abi = {
787
+ 'resource_drop_js-abi-value': (i) => { jsValueSlab.remove(i); },
788
+ 'resource_new_rb-abi-value': (i) => rbValueSlab.insert({ _wasm_val: i }),
789
+ 'resource_get_rb-abi-value': (i) => {
790
+ const r = rbValueSlab.get(i);
791
+ return r ? r._wasm_val : 0;
792
+ },
793
+ };
794
+
795
+ const jsAbiResources = jsValueSlab;
796
+ function readGuestString(ptr, len) {
797
+ return new TextDecoder().decode(new Uint8Array(memRef.buffer, ptr, len));
798
+ }
799
+ function writeGuestString(outPtr, value) {
800
+ const bytes = new TextEncoder().encode(String(value));
801
+ const strPtr = cabiRealloc(0, 0, 1, bytes.length);
802
+ new Uint8Array(memRef.buffer).set(bytes, strPtr);
803
+ const dv = new DataView(memRef.buffer);
804
+ dv.setUint32(outPtr + 0, strPtr, true);
805
+ dv.setUint32(outPtr + 4, bytes.length, true);
806
+ }
807
+ function writeJsResult(outPtr, tag, value) {
808
+ const dv = new DataView(memRef.buffer);
809
+ dv.setInt8(outPtr + 0, tag === 'success' ? 0 : 1, true);
810
+ dv.setInt32(outPtr + 4, jsAbiResources.insert(value), true);
811
+ }
812
+ function readJsHandle(id) {
813
+ return jsAbiResources.get(id);
814
+ }
815
+ function readJsHandleList(ptr, len) {
816
+ const dv = new DataView(memRef.buffer);
817
+ const out = [];
818
+ for (let i = 0; i < len; i++) out.push(readJsHandle(dv.getInt32(ptr + i * 4, true)));
819
+ return out;
820
+ }
821
+ function jsFailure(error) {
822
+ return error instanceof Error ? error : new Error(String(error));
823
+ }
824
+ const rb_js_abi_host = {
825
+ rb_wasm_throw_prohibit_rewind_exception: () => {
826
+ // This one CAN fire from Ruby internals (Fiber rewind guard).
827
+ // Make it a no-op so Ruby's continuation machinery proceeds.
828
+ },
829
+ 'eval-js: func(code: string) -> variant { success(handle<js-abi-value>), failure(handle<js-abi-value>) }': (ptr, len, outPtr) => {
830
+ try {
831
+ writeJsResult(outPtr, 'success', Function(readGuestString(ptr, len))());
832
+ } catch (e) {
833
+ writeJsResult(outPtr, 'failure', jsFailure(e));
834
+ }
835
+ },
836
+ 'is-js: func(value: handle<js-abi-value>) -> bool': () => 1,
837
+ 'instance-of: func(value: handle<js-abi-value>, klass: handle<js-abi-value>) -> bool': (value, klass) => {
838
+ const ctor = readJsHandle(klass);
839
+ return typeof ctor === 'function' && readJsHandle(value) instanceof ctor ? 1 : 0;
840
+ },
841
+ 'global-this: func() -> handle<js-abi-value>': () => jsAbiResources.insert(globalThis),
842
+ 'int-to-js-number: func(value: s32) -> handle<js-abi-value>': (value) => jsAbiResources.insert(value),
843
+ 'float-to-js-number: func(value: float64) -> handle<js-abi-value>': (value) => jsAbiResources.insert(value),
844
+ 'string-to-js-string: func(value: string) -> handle<js-abi-value>': (ptr, len) => jsAbiResources.insert(readGuestString(ptr, len)),
845
+ 'bool-to-js-bool: func(value: bool) -> handle<js-abi-value>': (value) => {
846
+ if (value !== 0 && value !== 1) throw new TypeError('Ruby JS bridge received an invalid bool value');
847
+ return jsAbiResources.insert(value === 1);
848
+ },
849
+ 'proc-to-js-function: func(value: u32) -> handle<js-abi-value>': () => jsAbiResources.insert(() => {
850
+ throw new Error('Nimbus Ruby JS bridge does not expose Ruby Proc callbacks yet');
851
+ }),
852
+ 'rb-object-to-js-rb-value: func(raw-rb-abi-value: u32) -> handle<js-abi-value>': (value) => jsAbiResources.insert({ __nimbusRubyValue: value >>> 0 }),
853
+ 'js-value-to-string: func(value: handle<js-abi-value>) -> string': (value, outPtr) => writeGuestString(outPtr, String(readJsHandle(value))),
854
+ 'js-value-to-integer: func(value: handle<js-abi-value>) -> variant { as-float(float64), bignum(string) }': (value, outPtr) => {
855
+ const raw = readJsHandle(value);
856
+ const dv = new DataView(memRef.buffer);
857
+ if (typeof raw === 'bigint') {
858
+ dv.setInt8(outPtr + 0, 1, true);
859
+ writeGuestString(outPtr + 8, raw.toString());
860
+ return;
861
+ }
862
+ dv.setInt8(outPtr + 0, 0, true);
863
+ dv.setFloat64(outPtr + 8, Number(raw), true);
864
+ },
865
+ 'export-js-value-to-host: func(value: handle<js-abi-value>) -> ()': (value) => {
866
+ globalThis.__nimbusRubyExportedJsValue = readJsHandle(value);
867
+ },
868
+ 'import-js-value-from-host: func() -> handle<js-abi-value>': () => jsAbiResources.insert(globalThis.__nimbusRubyExportedJsValue),
869
+ 'js-value-typeof: func(value: handle<js-abi-value>) -> string': (value, outPtr) => writeGuestString(outPtr, typeof readJsHandle(value)),
870
+ 'js-value-equal: func(lhs: handle<js-abi-value>, rhs: handle<js-abi-value>) -> bool': (lhs, rhs) => readJsHandle(lhs) == readJsHandle(rhs) ? 1 : 0,
871
+ 'js-value-strictly-equal: func(lhs: handle<js-abi-value>, rhs: handle<js-abi-value>) -> bool': (lhs, rhs) => readJsHandle(lhs) === readJsHandle(rhs) ? 1 : 0,
872
+ 'reflect-apply: func(target: handle<js-abi-value>, this-argument: handle<js-abi-value>, arguments: list<handle<js-abi-value>>) -> variant { success(handle<js-abi-value>), failure(handle<js-abi-value>) }': (target, thisArg, argsPtr, argsLen, outPtr) => {
873
+ try {
874
+ writeJsResult(outPtr, 'success', Reflect.apply(readJsHandle(target), readJsHandle(thisArg), readJsHandleList(argsPtr, argsLen)));
875
+ } catch (e) {
876
+ writeJsResult(outPtr, 'failure', jsFailure(e));
877
+ }
878
+ },
879
+ 'reflect-get: func(target: handle<js-abi-value>, property-key: string) -> variant { success(handle<js-abi-value>), failure(handle<js-abi-value>) }': (target, keyPtr, keyLen, outPtr) => {
880
+ try {
881
+ writeJsResult(outPtr, 'success', Reflect.get(readJsHandle(target), readGuestString(keyPtr, keyLen)));
882
+ } catch (e) {
883
+ writeJsResult(outPtr, 'failure', jsFailure(e));
884
+ }
885
+ },
886
+ 'reflect-set: func(target: handle<js-abi-value>, property-key: string, value: handle<js-abi-value>) -> variant { success(handle<js-abi-value>), failure(handle<js-abi-value>) }': (target, keyPtr, keyLen, value, outPtr) => {
887
+ try {
888
+ writeJsResult(outPtr, 'success', Reflect.set(readJsHandle(target), readGuestString(keyPtr, keyLen), readJsHandle(value)));
889
+ } catch (e) {
890
+ writeJsResult(outPtr, 'failure', jsFailure(e));
891
+ }
892
+ },
893
+ };
894
+
895
+ const imports = {
896
+ wasi_snapshot_preview1: wasi.wasiImport,
897
+ canonical_abi,
898
+ 'rb-js-abi-host': rb_js_abi_host,
899
+ };
900
+
901
+ let instance;
902
+ try {
903
+ const result = await WebAssembly.instantiate(rubyMod, imports);
904
+ instance = (result instanceof WebAssembly.Instance ? result : result.instance);
905
+ } catch (e) {
906
+ return { ok: false, error: 'WebAssembly.instantiate failed: ' + (e && e.message), stack: e && e.stack };
907
+ }
908
+ memRef = instance.exports.memory;
909
+
910
+ // Entering the Ruby VM.
911
+ //
912
+ // The WASI imports this instance is given include ones wrapped in
913
+ // WebAssembly.Suspending — fd_read, fd_write, fd_pread, path_filestat_get,
914
+ // poll_oneoff and the sock_* family. V8 requires an active
915
+ // WebAssembly.promising suspender for ANY call into a suspending import,
916
+ // whether or not that import returns a Promise (measured on workerd:
917
+ // a Suspending import returning a plain i32 off a raw stack throws
918
+ // SuspendError "trying to suspend without WebAssembly.promising"). So every
919
+ // entry into this instance is promising-wrapped, not just the ones that are
920
+ // known to park today: which WASI calls the guest makes is the guest's
921
+ // business, and the suspending set grows.
922
+ //
923
+ // cabi_realloc is deliberately not wrapped. It is the guest allocator, not a
924
+ // VM entry — it never reaches WASI, and it is reached from the synchronous
925
+ // rb-js-abi-host callbacks, which cannot await.
926
+ //
927
+ // Where there is no JSPI there is also nothing suspending to enter, so the
928
+ // wrapper is the identity: same VM, same call, on a plain stack.
929
+ const enterVm = (fn) => (__nimbusRubyParking === 'jspi' ? WebAssembly.promising(fn) : fn);
930
+
931
+ // ── Ruby bootstrap sequence ────────────────────────────────────
932
+ // Order matters (per ruby.wasm DefaultRubyVM):
933
+ // 1. _initialize (reactor entry; runs static initializers)
934
+ // 2. __wasi_vfs_rt_init (mount packed stdlib at the wasi-vfs's
935
+ // internal FS — needed for require to find Ruby's *.rb files)
936
+ // 3. ruby-init([progName]) — initialize VM with argv[0]
937
+ // 4. ruby-init-loadpath() — set $LOAD_PATH from packed stdlib
938
+ try {
939
+ if (typeof instance.exports._initialize === 'function') {
940
+ await enterVm(instance.exports._initialize)();
941
+ }
942
+ if (typeof instance.exports.__wasi_vfs_rt_init === 'function') {
943
+ await enterVm(instance.exports.__wasi_vfs_rt_init)();
944
+ }
945
+ } catch (e) {
946
+ return { ok: false, error: '_initialize/wasi_vfs_rt_init failed: ' + (e && e.message), stack: e && e.stack };
947
+ }
948
+
949
+ // Locate the canonical Ruby ABI exports. Names embed the WIT
950
+ // signature literal (e.g. 'ruby-init: func(args: list<string>) -> ()')
951
+ // because rb-abi-guest is wit-bindgen-generated.
952
+ const rubyInit = instance.exports['ruby-init: func(args: list<string>) -> ()'];
953
+ const rubyInitLoadpath = instance.exports['ruby-init-loadpath: func() -> ()'];
954
+ const rbEvalStringProtect = instance.exports['rb-eval-string-protect: func(str: string) -> tuple<handle<rb-abi-value>, s32>'];
955
+ const cabiRealloc = instance.exports.cabi_realloc;
956
+ if (!rubyInit || !rubyInitLoadpath || !rbEvalStringProtect || !cabiRealloc) {
957
+ return { ok: false, error: 'Required Ruby ABI exports missing (ruby-init/init-loadpath/eval-string-protect/cabi_realloc)' };
958
+ }
959
+
960
+ // Encode a list<string> argument for ruby-init. WIT canonical-ABI
961
+ // shape: caller allocates list buffer; each element is (ptr, len).
962
+ // Strings are UTF-8 encoded into separately-allocated buffers.
963
+ function writeListString(strings) {
964
+ const memory = instance.exports.memory;
965
+ const enc = new TextEncoder();
966
+ const len = strings.length;
967
+ const listBufPtr = cabiRealloc(0, 0, 4, len * 8); // align=4, size=len*8
968
+ const encoded = strings.map((s) => enc.encode(s));
969
+ for (let i = 0; i < len; i++) {
970
+ const bytes = encoded[i];
971
+ const strPtr = cabiRealloc(0, 0, 1, bytes.length);
972
+ new Uint8Array(memory.buffer).set(bytes, strPtr);
973
+ const dv = new DataView(memory.buffer);
974
+ dv.setUint32(listBufPtr + i * 8 + 0, strPtr, true);
975
+ dv.setUint32(listBufPtr + i * 8 + 4, bytes.length, true);
976
+ }
977
+ return { ptr: listBufPtr, len };
978
+ }
979
+
980
+ function writeString(s) {
981
+ const memory = instance.exports.memory;
982
+ const enc = new TextEncoder();
983
+ const bytes = enc.encode(s);
984
+ const ptr = cabiRealloc(0, 0, 1, bytes.length);
985
+ new Uint8Array(memory.buffer).set(bytes, ptr);
986
+ return { ptr, len: bytes.length };
987
+ }
988
+
989
+ // NOTE: We DO NOT call ruby-init or ruby-init-loadpath here. Both
990
+ // invoke CPython-like random-seed initialization (random_get via
991
+ // wasi_snapshot_preview1.random_get), which workerd blocks in the
992
+ // global-scope (module-init) context. Same constraint that bit us
993
+ // for Pyodide v2 P21. The per-call __rubyRun runs them at request-
994
+ // handler time where crypto.getRandomValues is permitted.
995
+ //
996
+ // _initialize and __wasi_vfs_rt_init are safe at module-init because
997
+ // they only do static initialization (no entropy reads).
998
+
999
+ return {
1000
+ ok: true,
1001
+ instance,
1002
+ wasi,
1003
+ rubyInit: enterVm(rubyInit),
1004
+ rubyInitLoadpath: enterVm(rubyInitLoadpath),
1005
+ rbEvalStringProtect: enterVm(rbEvalStringProtect),
1006
+ writeListString,
1007
+ writeString,
1008
+ rubyInitialized: false, // mutated to true by __rubyRun on first call
1009
+ };
1010
+ })();
1011
+
1012
+ // ── Per-call entry point ───────────────────────────────────────────
1013
+ //
1014
+ // Invoked from the LOADER child facet's execute() (which calls the
1015
+ // serialized facetFn that does globalThis.__rubyRun(args)).
1016
+ //
1017
+ // At this point the bootstrap promise has resolved (since it's
1018
+ // awaited inside the child facet's module-init context — the
1019
+ // instantiate finishes before the request handler runs). We:
1020
+ // 1. Update Ruby's $0 / $PROGRAM_NAME / ARGV via rb-eval-string-protect
1021
+ // 2. Wrap the user code in a begin/rescue SystemExit/StandardError
1022
+ // handler so we can extract exit code without losing stdout
1023
+ // 3. Read stdout/stderr buffers and slice from the per-call start
1024
+ // Evaluate Ruby source in the booted VM. Hoisted out of __rubyRun so a
1025
+ // process can also be DRIVEN (resumed) without re-running the whole
1026
+ // per-invocation wrapper.
1027
+ async function __nimbusRubyEval(boot, rubyCode) {
1028
+ const memory = boot.instance.exports.memory;
1029
+ const bytes = new TextEncoder().encode(rubyCode);
1030
+ const codePtr = boot.instance.exports.cabi_realloc(0, 0, 1, bytes.length);
1031
+ new Uint8Array(memory.buffer).set(bytes, codePtr);
1032
+ const retPtr = await boot.rbEvalStringProtect(codePtr, bytes.length);
1033
+ // Return is a tuple: (rb-abi-value handle u32, status s32) — 8 bytes
1034
+ const dv = new DataView(memory.buffer);
1035
+ return { handle: dv.getUint32(retPtr + 0, true), status: dv.getInt32(retPtr + 4, true) };
1036
+ }
1037
+
1038
+ // Resume the process's main fiber, and report what it wants next.
1039
+ //
1040
+ // A workerd request context cannot resume a wasm stack suspended by a
1041
+ // DIFFERENT request, so a server cannot simply block in accept across
1042
+ // requests. A Ruby fiber can: its state lives in the VM's own memory, so it
1043
+ // survives the context boundary. The process body therefore runs in a fiber
1044
+ // that parks when its accept queue is empty, and each inbound request resumes
1045
+ // it. Returns resumed=false when there is no live process to drive, which the
1046
+ // kernel reports as "nothing accepted the request".
1047
+ //
1048
+ // The report is what makes the process drivable at all:
1049
+ // alive the body is still running - it parked rather than finished
1050
+ // hostDriven it has listened, so inbound requests are what resume it now
1051
+ // wakeAfter seconds until the earliest deadline it owes, or null for none
1052
+ globalThis.__nimbusRubyResumeMain = async function __nimbusRubyResumeMain() {
1053
+ const boot = await globalThis.__rubyBootstrap;
1054
+ if (!boot.ok) return { resumed: false, alive: false, hostDriven: false, wakeAfter: null };
1055
+ const stderrStart = globalThis.__nimbusRubyStderr.length;
1056
+ await __nimbusRubyEval(boot, [
1057
+ '$__nimbus_resumed = ($__nimbus_main && $__nimbus_main.alive?) ? (begin; $__nimbus_main.resume; true; ' +
1058
+ 'rescue Exception => e; $stderr.write(e.full_message(highlight: false, order: :top)); $__nimbus_exit = 1; false; end) : false',
1059
+ '$stderr.write("__NIMBUS_RESUMED_" + $__nimbus_resumed.to_s' +
1060
+ ' + "_" + (($__nimbus_main && $__nimbus_main.alive?) ? "1" : "0")' +
1061
+ ' + "_" + ((defined?(Nimbus::Threading) && Nimbus::Threading.host_driven) ? "1" : "0")' +
1062
+ ' + "_" + ($__nimbus_wake_after ? $__nimbus_wake_after.to_s : "nil") + "\\n")',
1063
+ ].join("\\n"));
1064
+ // Scrub the marker so it never reaches the user's stderr, keeping whatever
1065
+ // the resumed program itself wrote.
1066
+ const written = globalThis.__nimbusRubyStderr.slice(stderrStart).join('');
1067
+ globalThis.__nimbusRubyStderr.length = stderrStart;
1068
+ const scrubbed = written.replace(/__NIMBUS_RESUMED_(true|false)_[^\\n]*\\n?/g, '');
1069
+ if (scrubbed) globalThis.__nimbusRubyStderr.push(scrubbed);
1070
+ const marker = /__NIMBUS_RESUMED_(true|false)_([01])_([01])_([^\\n]*)/.exec(written);
1071
+ const wake = marker && marker[4] !== 'nil' ? Number(marker[4]) : NaN;
1072
+ return {
1073
+ resumed: !!marker && marker[1] === 'true',
1074
+ alive: !!marker && marker[2] === '1',
1075
+ hostDriven: !!marker && marker[3] === '1',
1076
+ wakeAfter: Number.isFinite(wake) ? wake : null,
1077
+ };
1078
+ };
1079
+
1080
+ // One resume at a time, for the whole process. Several drivers can be live at
1081
+ // once — the request that queued a connection, another request waiting out a
1082
+ // deadline, the invocation that started the process — and two of them entering
1083
+ // a live fiber together would corrupt it. The queue is on globalThis because
1084
+ // no single request may own it: a request context is torn down without warning
1085
+ // when its response is sent, taking anything anchored to it.
1086
+ globalThis.__nimbusRubyResumeQueue = globalThis.__nimbusRubyResumeQueue || Promise.resolve();
1087
+ globalThis.__nimbusRubyStep = function __nimbusRubyStep() {
1088
+ const run = () => globalThis.__nimbusRubyResumeMain();
1089
+ const task = globalThis.__nimbusRubyResumeQueue.then(run, run);
1090
+ globalThis.__nimbusRubyResumeQueue = task.then(() => {}, () => {});
1091
+ return task;
1092
+ };
1093
+
1094
+ // Drive a process that has just been started, until it no longer owes the
1095
+ // clock anything.
1096
+ //
1097
+ // This is the whole of what a "boot driver" is: the clock only advances
1098
+ // between turns, so a body that parked on a deadline needs someone outside the
1099
+ // guest to wait out that deadline on a real timer and resume it. Without one,
1100
+ // the deadline can never pass and the invocation burns its CPU budget instead.
1101
+ //
1102
+ // It stops the moment the process listens: from there the process is resumed
1103
+ // by inbound requests, and those requests carry the deadlines — a driver
1104
+ // anchored to this invocation would be cancelled with it.
1105
+ globalThis.__nimbusRubyDriveBoot = async function __nimbusRubyDriveBoot() {
1106
+ for (;;) {
1107
+ const step = await globalThis.__nimbusRubyStep();
1108
+ if (!step.resumed || !step.alive) return step;
1109
+ if (step.hostDriven || step.wakeAfter === null) return step;
1110
+ // Always through a timer, even at zero: the turn boundary is what moves
1111
+ // the clock, so resuming without one would leave the deadline where it was.
1112
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, step.wakeAfter) * 1000));
1113
+ }
1114
+ };
1115
+
1116
+ globalThis.__rubyRun = async function __rubyRun(args) {
1117
+ const stdoutStart = globalThis.__nimbusRubyStdout.length;
1118
+ const stderrStart = globalThis.__nimbusRubyStderr.length;
1119
+
1120
+ const boot = await globalThis.__rubyBootstrap;
1121
+ if (!boot.ok) {
1122
+ return {
1123
+ exitCode: 1,
1124
+ stdout: globalThis.__nimbusRubyStdout.slice(stdoutStart).join(''),
1125
+ stderr: globalThis.__nimbusRubyStderr.slice(stderrStart).join(''),
1126
+ error: 'ruby bootstrap failed: ' + (boot.error || 'unknown') + (boot.stack ? ' [stack=' + boot.stack + ']' : ''),
1127
+ };
1128
+ }
1129
+
1130
+ try {
1131
+ __nimbusInstallRubyFsSnapshot(args.fsSnapshot);
1132
+ // AFTER the mount, never before. __wasiInitFS deliberately drops the
1133
+ // supervisor so a pooled isolate cannot serve the previous tenant's
1134
+ // filesystem, which means adopting first — as both ruby entry points do,
1135
+ // since they must adopt before they know whether a mount is coming —
1136
+ // leaves the seed with no backing store for the whole script load.
1137
+ // Every require then read a manifest entry with nothing behind it.
1138
+ __wasiAdoptSupervisor(globalThis.__nimbusRubySupervisor);
1139
+ } catch (e) {
1140
+ globalThis.__nimbusRubyStderr.push('[ruby-runner] VFS mount failed: ' + (e && e.message) + '\\n');
1141
+ }
1142
+
1143
+ // First call into __rubyRun: complete Ruby VM init (ruby-init +
1144
+ // ruby-init-loadpath) now that we're in request-handler context
1145
+ // where crypto.getRandomValues is permitted. Subsequent calls skip.
1146
+ //
1147
+ // The language prelude goes in here, once, with the rest of VM startup.
1148
+ // Threads, queues, mutexes and the socket classes are what Ruby IS on this
1149
+ // runtime, so a program gets them because it is Ruby - not because the
1150
+ // invocation was classified one way rather than another. The two process
1151
+ // shapes differ in how long the process lives, and in nothing else.
1152
+ if (!boot.rubyInitialized) {
1153
+ try {
1154
+ const initArgs = boot.writeListString(['ruby', '-e_=0']);
1155
+ await boot.rubyInit(initArgs.ptr, initArgs.len);
1156
+ await boot.rubyInitLoadpath();
1157
+ boot.rubyInitialized = true;
1158
+ } catch (e) {
1159
+ return {
1160
+ exitCode: 1,
1161
+ stdout: globalThis.__nimbusRubyStdout.slice(stdoutStart).join(''),
1162
+ stderr: globalThis.__nimbusRubyStderr.slice(stderrStart).join(''),
1163
+ error: 'ruby-init / ruby-init-loadpath failed at request time: ' + (e && e.message),
1164
+ };
1165
+ }
1166
+ // A broken language prelude is a broken interpreter, so it fails the call
1167
+ // rather than leaving the program to trip over whatever is missing.
1168
+ let preludeStatus;
1169
+ try {
1170
+ preludeStatus = await __nimbusRubyEval(boot, RUBY_LANGUAGE_PRELUDE);
1171
+ } catch (e) {
1172
+ preludeStatus = { status: -1, error: (e && e.message) || String(e) };
1173
+ }
1174
+ if (!preludeStatus || preludeStatus.status !== 0) {
1175
+ boot.rubyInitialized = false;
1176
+ return {
1177
+ exitCode: 1,
1178
+ stdout: globalThis.__nimbusRubyStdout.slice(stdoutStart).join(''),
1179
+ stderr: globalThis.__nimbusRubyStderr.slice(stderrStart).join(''),
1180
+ error: 'ruby language prelude failed to load: ' +
1181
+ (preludeStatus && preludeStatus.error ? preludeStatus.error : 'eval status ' + (preludeStatus && preludeStatus.status)),
1182
+ };
1183
+ }
1184
+ }
1185
+
1186
+ function rubyStringLiteral(value) {
1187
+ const s = String(value ?? '');
1188
+ let out = "'";
1189
+ for (let i = 0; i < s.length; i++) {
1190
+ const ch = s[i];
1191
+ if (ch === "\\\\") out += "\\\\\\\\";
1192
+ else if (ch === "'") out += "\\\\'";
1193
+ else out += ch;
1194
+ }
1195
+ return out + "'";
1196
+ }
1197
+
1198
+ function rubyArrayLiteral(values) {
1199
+ return '[' + (values || []).map((v) => rubyStringLiteral(v)).join(', ') + ']';
1200
+ }
1201
+
1202
+ function rubyHashLiteral(obj) {
1203
+ return '{' + Object.entries(obj || {})
1204
+ .map(([k, v]) => rubyStringLiteral(k) + ' => ' + rubyStringLiteral(v))
1205
+ .join(', ') + '}';
1206
+ }
1207
+
1208
+ // Wrapper code: set $0/$PROGRAM_NAME/ARGV/ENV, run user code,
1209
+ // capture SystemExit. User-controlled strings are emitted as Ruby
1210
+ // single-quoted literals so Ruby interpolation inside source text is
1211
+ // preserved for the user's eval, not consumed by this wrapper.
1212
+ //
1213
+ // The wrapper sets __NIMBUS_RUBY_EXIT to the desired exit code so
1214
+ // we can read it via a second rb-eval-string-protect call. Failing
1215
+ // SystemExit (raise) ends up with __NIMBUS_RUBY_EXIT = 1 + stderr
1216
+ // message.
1217
+ const userCodeRb = rubyStringLiteral(args.userCode);
1218
+ const argvRb = rubyArrayLiteral(args.rbArgv.slice(1)); // exclude argv[0]
1219
+ const progNameRb = rubyStringLiteral(args.progName);
1220
+
1221
+ // STAGED execution: we split the prelude (stdout sync + ARGV/ENV/$0
1222
+ // setup) from the user-code eval. The prelude has no failure modes
1223
+ // we care about; user-code is wrapped in begin/rescue for SystemExit
1224
+ // and Exception. Wrapper failures are reported through the captured
1225
+ // stderr diagnostic stream.
1226
+ // Build env list as string-keyed Ruby hash via the rocket-syntax.
1227
+ // Ruby treats colon-style hash literals as Symbol-keyed; we need
1228
+ // String keys so ENV[k] = v works without TypeError.
1229
+ const envHashRb = rubyHashLiteral(args.userEnv || {});
1230
+ const cwdRb = rubyStringLiteral(args.cwd || '/home/user');
1231
+
1232
+ const preludeRb = [
1233
+ // Reset exit state FIRST so partial prelude failures still
1234
+ // surface a clean exit code (previously: exit 7 left $__nimbus_exit
1235
+ // = 7 → next call's prelude could fail before resetting → second
1236
+ // exit 0 returned 7).
1237
+ '$__nimbus_exit = 0',
1238
+ '$stdout.sync = true',
1239
+ '$stderr.sync = true',
1240
+ '$0 = ' + progNameRb,
1241
+ '$PROGRAM_NAME = ' + progNameRb,
1242
+ 'ARGV.replace(' + argvRb + ')',
1243
+ envHashRb + '.each_pair { |k, v| ENV[k] = v }',
1244
+ 'ENV["HOME"] ||= "/home/user"',
1245
+ 'ENV["GEM_HOME"] ||= File.join(ENV["HOME"], ".gem")',
1246
+ 'ENV["GEM_PATH"] ||= ENV["GEM_HOME"]',
1247
+ 'begin; Dir.mkdir(ENV["GEM_HOME"]) unless Dir.exist?(ENV["GEM_HOME"]); rescue Exception; end',
1248
+ 'begin; Dir.chdir(' + cwdRb + '); rescue Exception; end',
1249
+ 'begin; $LOAD_PATH.unshift(Dir.pwd) unless $LOAD_PATH.include?(Dir.pwd); rescue Exception; end',
1250
+ 'begin; (ENV["NIMBUS_GEM_LIBS"] || "").split(":").reverse_each { |p| $LOAD_PATH.unshift(p) if p && p != "" && !$LOAD_PATH.include?(p) }; rescue Exception; end',
1251
+ ].join('; ');
1252
+
1253
+ // The body runs in a fiber; every resume of it goes through the driver
1254
+ // below. A program with no server runs to completion across as many turns as
1255
+ // its deadlines need; a server parks in accept when its queue is empty and
1256
+ // is driven from there, one inbound request at a time. Same fiber, same
1257
+ // driver, so this is the single path for every Ruby invocation.
1258
+ const userWrapper = [
1259
+ '$__nimbus_main = Fiber.new do',
1260
+ ' begin',
1261
+ ' ' + 'eval(' + userCodeRb + ', TOPLEVEL_BINDING, ' + progNameRb + ', 1)',
1262
+ ' rescue SystemExit => e',
1263
+ ' $__nimbus_exit = e.status',
1264
+ ' rescue Exception => e',
1265
+ ' $stderr.write(e.full_message(highlight: false, order: :top))',
1266
+ ' $__nimbus_exit = 1',
1267
+ ' ensure',
1268
+ ' begin; Nimbus::Threading.shutdown if defined?(Nimbus::Threading); rescue Exception; end',
1269
+ ' $stdout.flush rescue nil',
1270
+ ' $stderr.flush rescue nil',
1271
+ ' end',
1272
+ 'end',
1273
+ ].join("\\n");
1274
+
1275
+ const callEvalStringProtect = (rubyCode) => __nimbusRubyEval(boot, rubyCode);
1276
+
1277
+ // Stage 1: run the prelude (sync flags, ARGV, ENV, $0/$PROGRAM_NAME).
1278
+ let preludeStatus;
1279
+ try {
1280
+ preludeStatus = await callEvalStringProtect(preludeRb);
1281
+ } catch (e) {
1282
+ return {
1283
+ exitCode: 1,
1284
+ stdout: globalThis.__nimbusRubyStdout.slice(stdoutStart).join(''),
1285
+ stderr: globalThis.__nimbusRubyStderr.slice(stderrStart).join(''),
1286
+ error: 'ruby prelude threw: ' + (e && e.message),
1287
+ };
1288
+ }
1289
+ if (preludeStatus && preludeStatus.status !== 0) {
1290
+ globalThis.__nimbusRubyStderr.push('[ruby-runner-diag] prelude returned non-zero status: ' + preludeStatus.status + '\\n');
1291
+ }
1292
+
1293
+ // Stage 2: build the body fiber wrapped for SystemExit/Exception capture,
1294
+ // then drive it until it finishes or hands itself to the host.
1295
+ let evalStatus;
1296
+ try {
1297
+ evalStatus = await callEvalStringProtect(userWrapper);
1298
+ await globalThis.__nimbusRubyDriveBoot();
1299
+ } catch (e) {
1300
+ return {
1301
+ exitCode: 1,
1302
+ stdout: globalThis.__nimbusRubyStdout.slice(stdoutStart).join(''),
1303
+ stderr: globalThis.__nimbusRubyStderr.slice(stderrStart).join(''),
1304
+ error: 'rb-eval-string-protect threw: ' + (e && e.message),
1305
+ };
1306
+ }
1307
+ if (evalStatus && evalStatus.status !== 0) {
1308
+ globalThis.__nimbusRubyStderr.push('[ruby-runner-diag] user wrapper returned non-zero status: ' + evalStatus.status + '\\n');
1309
+ }
1310
+
1311
+ // Read $__nimbus_exit through a sentinel on captured stderr, then remove
1312
+ // the sentinel before returning user-visible output.
1313
+ const NIMBUS_EXIT_MARKER = '__NIMBUS_RUBY_EXIT_';
1314
+ let exitCode = 0;
1315
+ try {
1316
+ // Print the marker + exit code to stderr (a side channel separate
1317
+ // from user-visible stdout). We strip it before returning.
1318
+ await callEvalStringProtect(
1319
+ '$stderr.write(' + JSON.stringify(NIMBUS_EXIT_MARKER) + ' + $__nimbus_exit.to_s + "\\\\n")'
1320
+ );
1321
+ // Scrape the marker from stderr buffer — using ONLY this call's
1322
+ // slice (from stderrStart). The same facet can be reused across
1323
+ // multiple __rubyRun invocations (loader-pool dedup by tag), so
1324
+ // a previous call's marker would otherwise be matched first.
1325
+ const callStderr = globalThis.__nimbusRubyStderr.slice(stderrStart).join('');
1326
+ // Match the LAST marker in this slice (the one our just-completed
1327
+ // call emitted; if the user wrapper also emitted writes, the
1328
+ // marker is appended after them).
1329
+ const markerRe = new RegExp(NIMBUS_EXIT_MARKER + '(-?\\\\d+)', 'g');
1330
+ let lastMatch = null;
1331
+ let mit;
1332
+ while ((mit = markerRe.exec(callStderr)) !== null) lastMatch = mit;
1333
+ if (lastMatch) exitCode = parseInt(lastMatch[1], 10);
1334
+ } catch (e) {
1335
+ // Failure to read exit code → assume 0 if no errors observed.
1336
+ exitCode = 0;
1337
+ }
1338
+
1339
+ // Scrub the marker out of the BUFFER, not just out of what this call
1340
+ // returns. A process that parked instead of exiting - any server - leaves
1341
+ // __rubyRun finished while the program is still live, and whoever reads the
1342
+ // buffer next would otherwise hand the user our side channel.
1343
+ const stdoutOut = globalThis.__nimbusRubyStdout.slice(stdoutStart).join('');
1344
+ const markerLine = new RegExp(NIMBUS_EXIT_MARKER + '-?\\\\d+\\\\n?', 'g');
1345
+ const stderrOut = globalThis.__nimbusRubyStderr.slice(stderrStart).join('').replace(markerLine, '');
1346
+ globalThis.__nimbusRubyStderr.length = stderrStart;
1347
+ if (stderrOut) globalThis.__nimbusRubyStderr.push(stderrOut);
1348
+
1349
+ return {
1350
+ exitCode: exitCode,
1351
+ stdout: stdoutOut,
1352
+ stderr: stderrOut,
1353
+ };
1354
+ };
1355
+
1356
+ // ── END: ruby-runner preamble ──────────────────────────────────────
1357
+ `;