@nimbus-sh/worker 0.2.1 → 0.2.2

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