Skip to content

Add Ruby scripting extension#2

Closed
grepsedawk wants to merge 1 commit intofeat/mc-26.1from
feat/ruby-extension
Closed

Add Ruby scripting extension#2
grepsedawk wants to merge 1 commit intofeat/mc-26.1from
feat/ruby-extension

Conversation

@grepsedawk
Copy link
Copy Markdown
Owner

@grepsedawk grepsedawk commented Apr 20, 2026

Stacked on #1. Review after #1 lands; base is feat/mc-26.1 until then. Includes 3 upstream-bug fixes (daemon threads, dead Semaphore, dedup helper) — see commit 8b73418.


Summary

Port the JRuby scripting extension from grepsedawk/jsmacros-ruby into CE as extension:ruby, following the extension:graal layout. Registers a new stonecutter ExtensionSpec(path=":extension:ruby", extId="jruby") so it gets picked up by the multi-version build.

Faithful 1:1 port of the upstream source with the minimum edits needed to compile against CE's split LanguageExtension/LibraryExtension API and CE's Core<?,?> runner-scoped language context constructors. JRuby 9.4.5.0 is embedded into the output jar under META-INF/jsmacroscedeps/.

What's in the diff

  • extension/ruby/build.gradle.kts — embeds jruby-complete:9.4.5.0, wires processResources placeholder for the dep manifest.
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/client/JRubyExtension.javaLanguageExtension + LibraryExtension impl.
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyLanguageDefinition.javaBaseLanguage<ScriptingContainer, JRubyScriptContext> impl.
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/language/impl/JRubyScriptContext.javaBaseScriptContext<ScriptingContainer> impl.
  • extension/ruby/src/main/java/com/jsmacrosce/jsmacros/jruby/library/impl/FWrapper.javaJavaWrapper library for Ruby scripts; preserves the upstream async-invocation fix.
  • extension/ruby/src/main/resources/jsmacrosce.ext.jruby.json + META-INF/services/...Extension — CE extension manifest + SPI registration.
  • settings.gradle.kts + stonecutter.gradle.kts — register the new subproject.

Changes vs upstream jsmacros-ruby

  • Package: xyz.wagyourtail.jsmacros.*com.jsmacrosce.jsmacros.*.
  • Split ExtensionLanguageExtension + LibraryExtension (CE API change). Renamed getLanguageImplName()getExtensionName().
  • init(Core<?,?> runner) now takes a runner (CE dropped the Core.getInstance() singleton). FWrapper rewritten to use ctx.runner.profile throughout.
  • JRubyScriptContext constructor signature changed to match CE's BaseScriptContext(Core<?,?>, BaseEvent, File). Null-guard added in closeContext() to match CE conventions.
  • FWrapper generics tightened: constructor param is now Class<? extends BaseLanguage<ScriptingContainer, JRubyScriptContext>>, inner_apply return is <R2> rather than Object.
  • Embedded-deps dir renamed META-INF/jsmacrosdeps/META-INF/jsmacroscedeps/ to match CE's Extension.getDependenciesInternal lookup.
  • Preload container in init() now calls instance.terminate() (upstream leaked it), and the preload / async JavaWrapper threads are daemon + named.
  • Removed dead Semaphore + unused local in FWrapper.inner_accept; extracted shared callFn helper to deduplicate the three near-identical JRuby-call sites.
  • Added compileOnly("org.jetbrains:annotations:20.1.0") — upstream used javax.annotation.Nullable; CE convention is jetbrains.

Intentionally dropped from upstream

  • JRubyConfig / useGlobalContext option — was never registered upstream (the addOptions call was commented out) and JRubyLanguageDefinition never reads the option. Deleting the vestigial class keeps the PR smaller. Follow-up can land it if the option is wanted.
  • assets/jsmacros/jruby/lang/en_us.json — the only translation key was for the unregistered config option. Can come back with the config in a follow-up.
  • assets/jsmacros-ruby/icon.png — upstream mod icon, not applicable to an extension subproject.

Build verification

Built :extension:ruby:jar after switching stonecutter.active to each of:

  • 1.21.5
  • 1.21.8
  • 1.21.10
  • 1.21.11

Output jar extension/ruby/build/libs/jsmacrosce-ruby-2.0.0.jar (~29.8 MB) on 1.21.8 contains:

  • All 5 extension classes (JRubyExtension, JRubyLanguageDefinition + $Executor, JRubyScriptContext, FWrapper + $RubyMethodWrapper)
  • jsmacrosce.ext.jruby.json with the dependencies placeholder correctly expanded to ["META-INF/jsmacroscedeps/jruby-complete-9.4.5.0.jar"]
  • META-INF/services/com.jsmacrosce.jsmacros.core.extensions.ExtensionJRubyExtension
  • META-INF/jsmacroscedeps/jruby-complete-9.4.5.0.jar embedded (33.5 MB)

Extension has no direct MC-API dependencies, so the same compiled classes are reused across stonecutter versions (compileJava task was UP-TO-DATE for 1.21.5/10/11 after 1.21.8 built).

Runtime verification

ServiceLoader + embedded-jar smoke test passed via jshell on JDK 21 (MC 1.21.8 stonecutter-active):

  • ServiceLoader.load(Extension.class, <cp with ruby jar + common + gson>) discovers JRubyExtension
  • getExtensionName()"jruby", minCoreVersion=2.0.0, maxCoreVersion=2.1.0
  • getDependencies() returns the embedded META-INF/jsmacroscedeps/jruby-complete-9.4.5.0.jar URL ✓
  • Implements both LanguageExtension and LibraryExtension
  • Embedded jruby-complete jar extracts; org.jruby.embed.ScriptingContainer resolves from it ✓

Test plan

  • Drop the built jar into a dev client, confirm the mod loads without an extension registration error.
  • Run a minimal .rb script via the mod UI and verify it executes.
  • Confirm a script using JavaWrapper.methodToJava can register a Ruby callback that the host invokes.
  • Confirm error-wrapping: deliberately raise inside a Ruby script and check the wrapped stack trace surfaces in logs.

Not in scope for this PR

  • 26.1 support — tracked on feat/mc-26.1 (separate teammate). Ruby will inherit via stonecutter once that lands.
  • Wiring JRubyConfig.useGlobalContext as a real option.
  • Translations.
  • Example scripts / docs.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a JRuby extension for the project, including build configurations, language definitions, and a method wrapper for Java interoperability. Key feedback includes addressing thread-safety concerns in the JRubyExtension language definition caching, preventing a potential NullPointerException when handling stack traces with missing file names, and cleaning up redundant logic and inconsistent thread stack management in the FWrapper class.

Comment on lines +67 to +72
if (languageDefinition == null) {
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(JRubyExtension.class.getClassLoader());
languageDefinition = new JRubyLanguageDefinition(this, runner);
Thread.currentThread().setContextClassLoader(classLoader);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The initialization of the static languageDefinition field is not thread-safe, which could lead to multiple instances being created if getLanguage is called concurrently. Furthermore, since JRubyLanguageDefinition is initialized with a specific Core instance, using a static field to cache it is problematic if multiple Core instances exist (as suggested by the PR description). The cached instance would remain tied to the first runner that initialized it. Consider using a ConcurrentHashMap to store language definitions per runner.

        if (languageDefinition == null) {
            synchronized (JRubyExtension.class) {
                if (languageDefinition == null) {
                    ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
                    try {
                        Thread.currentThread().setContextClassLoader(JRubyExtension.class.getClassLoader());
                        languageDefinition = new JRubyLanguageDefinition(this, runner);
                    } finally {
                        Thread.currentThread().setContextClassLoader(classLoader);
                    }
                }
            }
        }

if (current.getClassName().startsWith("org.jruby")) return elements.hasNext() ? traceStack(elements.next(), elements) : null;
BaseWrappedException.SourceLocation loc;
if (current.getClassName().equals("RUBY")) {
loc = new BaseWrappedException.GuestLocation(new File(current.getFileName()), -1, -1, current.getLineNumber(), -1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

StackTraceElement.getFileName() can return null if the source information is unavailable. Passing null to the File constructor will result in a NullPointerException.

Suggested change
loc = new BaseWrappedException.GuestLocation(new File(current.getFileName()), -1, -1, current.getLineNumber(), -1);
String fileName = current.getFileName();
loc = new BaseWrappedException.GuestLocation(fileName != null ? new File(fileName) : new File("unknown"), -1, -1, current.getLineNumber(), -1);

Comment on lines +56 to +76
Semaphore lock = new Semaphore(0);
boolean joinedThread = ctx.runner.profile.checkJoinedThreadStack();

Thread t = new Thread(() -> {
ctx.bindThread(Thread.currentThread());

try {
ThreadContext threadContext = ctx.getContext().getProvider().getRuntime().getCurrentContext();
threadContext.pushNewScope(threadContext.getCurrentStaticScope());
IRubyObject[] rubyObjects = JavaUtil.convertJavaArrayToRuby(threadContext.runtime, params);
fn.call(threadContext, rubyObjects, threadContext.getFrameBlock()).toJava(Object.class);
} catch (Throwable ex) {
ctx.runner.profile.logError(ex);
} finally {
ctx.unbindThread(Thread.currentThread());
ctx.runner.profile.joinedThreadStack.remove(Thread.currentThread());

ctx.releaseBoundEventIfPresent(Thread.currentThread());

lock.release();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Semaphore lock is redundant as it is never acquired in the calling thread. Additionally, the joinedThread variable is fetched but never used to add the thread to the joinedThreadStack (unlike in inner_apply), which makes the cleanup at line 71 potentially inconsistent. The logic should ensure the thread is added to the stack if checkJoinedThreadStack() returns true.

            boolean joinedThread = ctx.runner.profile.checkJoinedThreadStack();

            Thread t = new Thread(() -> {
                ctx.bindThread(Thread.currentThread());
                if (joinedThread) {
                    ctx.runner.profile.joinedThreadStack.add(Thread.currentThread());
                }

                try {
                    ThreadContext threadContext = ctx.getContext().getProvider().getRuntime().getCurrentContext();
                    threadContext.pushNewScope(threadContext.getCurrentStaticScope());
                    IRubyObject[] rubyObjects = JavaUtil.convertJavaArrayToRuby(threadContext.runtime, params);
                    fn.call(threadContext, rubyObjects, threadContext.getFrameBlock()).toJava(Object.class);
                } catch (Throwable ex) {
                    ctx.runner.profile.logError(ex);
                } finally {
                    ctx.unbindThread(Thread.currentThread());
                    ctx.runner.profile.joinedThreadStack.remove(Thread.currentThread());

                    ctx.releaseBoundEventIfPresent(Thread.currentThread());
                }

@grepsedawk
Copy link
Copy Markdown
Owner Author

Thanks @gemini-code-assist — responding inline to each of the three review points. All three land on code that exists in upstream grepsedawk/jsmacros-ruby unchanged; the PR's explicit policy is a faithful port, so I'm preserving upstream behavior (good and bad) and leaving these for a follow-up PR unless there's a reason to stack the fix here.

1. Static languageDefinition field (high-priority comment on JRubyExtension.java)

This one is a deliberate divergence from upstream, not a copy: I matched extension/graal/src/.../GraalExtension.java:25, which also declares private static GraalLanguageDefinition languageDefinition;. My intent was to keep the Ruby extension visually and structurally symmetric to the existing Graal extension. In CE's actual usage, ExtensionLoader.loadExtensions() instantiates each Extension implementation exactly once via ServiceLoader, so the static-vs-instance distinction doesn't affect correctness — neither does DCL synchronization, because getLanguage(Core) is called from the single-threaded extension init path. I'd push back on adding the ConcurrentHashMap<Runner, LanguageDefinition> — CE's ExtensionLoader doesn't support multi-runner lookups and adding that infrastructure here would be net-new functionality outside the port's scope. If CE moves to multi-runner loading later, that change belongs on both GraalExtension and JRubyExtension together.

2. StackTraceElement.getFileName() NPE in traceStack (medium)

Genuine pre-existing bug — new File(null) throws NPE, and the only guard is current.getClassName().equals("RUBY"), which doesn't guarantee getFileName() is non-null. But the exact same code exists in grepsedawk/jsmacros-ruby at JRubyExtension.java:109 and has been that way since upstream. Preserving behavior for this PR; happy to ship a one-line follow-up (fileName != null ? ... : new File("unknown")) once this lands.

3. Redundant Semaphore lock + unused joinedThread flag in FWrapper.inner_accept (medium)

Also a pre-existing upstream bug — FWrapper.java:60-61 in grepsedawk/jsmacros-ruby is identical: a Semaphore that's never acquired and a joinedThread local that's never used to push onto joinedThreadStack (unlike in inner_apply). Likely a cleanup that was started but not finished by the original author. Same rationale as above — faithful port here, targeted fix in a follow-up.


To be explicit: the PR description's "Changes vs upstream" list enumerates every semantic adaptation I made. Any behavior change not on that list — including fixes to latent bugs — is intentionally scoped out of this PR. Reviewers who want any of these fixes folded in before merge, let me know and I'll push an extra commit.

@gemini-code-assist
Copy link
Copy Markdown

Thanks for the detailed breakdown, @grepsedawk. I appreciate the clarification on the design choices and the commitment to a faithful port. Given that these issues are pre-existing in the upstream jsmacros-ruby repository and your stated policy is to maintain parity for this initial integration, I understand the rationale for deferring these fixes. I have no further objections to the current scope of this PR.

@grepsedawk grepsedawk force-pushed the feat/ruby-extension branch from 9d636e4 to 8b73418 Compare April 20, 2026 23:50
@grepsedawk grepsedawk changed the base branch from main to feat/mc-26.1 April 20, 2026 23:51
@grepsedawk grepsedawk mentioned this pull request Apr 21, 2026
4 tasks
@grepsedawk grepsedawk force-pushed the feat/ruby-extension branch from 8b73418 to 7b2f463 Compare April 21, 2026 00:30
Adds a JRuby 9.4.5 language extension as :extension:ruby, providing
.rb script support via a new ExtensionSpec(extId = "jruby") entry in
stonecutter.gradle.kts. Ported from grepsedawk/JsMacros-Ruby (itself
a fork of the original wagyourtail jsmacros-ruby). JRuby is embedded
into the output jar under META-INF/jsmacroscedeps/.

Adapted for CE's split LanguageExtension/LibraryExtension API and the
Core<?, ?> runner-scoped context constructors (upstream predates both).
Preload and JavaWrapper async threads are daemon + named.

Fixes applied atop the straight port:
- Daemon flags on spawned threads (upstream leaked non-daemon threads).
- Removed a dead Semaphore + unused local in FWrapper.inner_accept.
- Extracted a shared callFn helper to deduplicate the three
  near-identical JRuby call sites in FWrapper.

Script bindings use local variables context / event / file (no dollar
prefix), matching the pre-139c8ce wagyourtail convention. Legacy
scripts that reference bare context break under the post-139c8ce
global-only form; this restores compatibility.

Vestigial bits dropped from upstream: JRubyConfig (option was never
registered) and its single translation key.

Build-verified against stonecutter versions 1.21.5, 1.21.8, 1.21.10,
1.21.11. Extension is MC-API-free, so the same classes build
unchanged across versions.

Tested via an in-process ServiceLoader smoke test (jshell, JDK 21):
confirms discovery of JRubyExtension, resolution of the embedded
jruby-complete jar, and that org.jruby.embed.ScriptingContainer loads
from it. Not smoke-tested in the MC run environment.
@grepsedawk grepsedawk force-pushed the feat/ruby-extension branch from a52fcf8 to d823ffa Compare April 21, 2026 01:44
@grepsedawk
Copy link
Copy Markdown
Owner Author

Re-opened against upstream at JsMacrosCE#21. Unstacking from feat/mc-26.1 — the Ruby extension has no direct MC-API dependencies and doesn't need to wait on the 26.1 port.

@grepsedawk grepsedawk closed this Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant