@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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/_shared/tarball-stream.d.ts +93 -0
- package/dist/_shared/tarball-stream.d.ts.map +1 -0
- package/dist/_shared/tarball-stream.js +235 -0
- package/dist/_shared/tarball.d.ts +17 -0
- package/dist/_shared/tarball.d.ts.map +1 -0
- package/dist/_shared/tarball.js +39 -0
- package/dist/runtime/clang-runner.d.ts +38 -0
- package/dist/runtime/clang-runner.d.ts.map +1 -0
- package/dist/runtime/clang-runner.js +866 -0
- package/dist/runtime/facet-host.d.ts +12 -0
- package/dist/runtime/facet-host.d.ts.map +1 -1
- package/dist/runtime/local-facet-host.d.ts.map +1 -1
- package/dist/runtime/local-facet-host.js +29 -9
- package/dist/runtime/ruby-gems.d.ts +30 -0
- package/dist/runtime/ruby-gems.d.ts.map +1 -0
- package/dist/runtime/ruby-gems.js +636 -0
- package/dist/runtime/ruby-runner.d.ts +127 -0
- package/dist/runtime/ruby-runner.d.ts.map +1 -0
- package/dist/runtime/ruby-runner.js +1357 -0
- package/dist/runtime/session-process-supervisor.d.ts +15 -0
- package/dist/runtime/session-process-supervisor.d.ts.map +1 -1
- package/dist/runtime/session-process-supervisor.js +30 -0
- package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
- package/dist/workspace/nimbus-workspace.js +9 -2
- package/package.json +4 -2
- package/src/_shared/tarball-stream.ts +263 -0
- package/src/_shared/tarball.ts +46 -0
- package/src/runtime/clang-runner.ts +924 -0
- package/src/runtime/facet-host.ts +12 -0
- package/src/runtime/local-facet-host.ts +28 -8
- package/src/runtime/ruby-gems.ts +682 -0
- package/src/runtime/ruby-runner.ts +1484 -0
- package/src/runtime/session-process-supervisor.ts +27 -0
- 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
|
+
`;
|