Skip to content

jvm-npm.js rewrite dropped Function-wrapper module isolation — const/let redeclaration crash across sibling requires #564

Description

@djbclark

TypeError: redeclaration of var X when two required files share a top-level const/let binding name

Symptom

Given a multi-file project like:

// lib/log.js
exports.append = function (line) { return line; };

// lib/a.js
const log = require("./log.js");
exports.tag = "a";

// lib/b.js
const log = require("./log.js");
exports.tag = "b";

// main.js
const a = require("./lib/a.js");
const b = require("./lib/b.js");

Running main.js crashes the instant the second file loads:

org.mozilla.javascript.EcmaError: TypeError: redeclaration of var log. (file:///android_asset/modules/jvm-npm.js#67)
	at org.mozilla.javascript.ScriptRuntime.constructError(...)
	at org.mozilla.javascript.ScriptableObject.redefineProperty(...)
	at org.mozilla.javascript.ScriptRuntime.initScript(...)
	at org.mozilla.javascript.Interpreter$CallFrame.initializeArgs(...)

Reproducible on-device (confirmed on a Pixel 7a — full incident: djbclark/stayturgid#34), independent of const/let/var, and independent of which file is the entry script. Because any real multi-file project routinely shares common dependency names (log, config, notify, etc.) across files, this affects every file that reuses a common local binding name, not just one.

What I believe is the root cause

app/src/main/assets/modules/jvm-npm.js in this repo has been substantially rewritten from upstream nodyn/jvm-npm (confirmed identical between SuperMonster003/AutoJs6 and this fork's copy — this isn't fork-specific). The rewrite changed how Module._load actually loads a required file's code:

Upstream nodyn/jvm-npm (Module._load):

var body = readFile(module.filename, module.core);
var func = new Function('exports', 'module', 'require', '__filename', '__dirname', body);
func.apply(module, [module.exports, module, module.require, module.filename, dir]);

Each required file's source becomes the body of a freshly-constructed Function, invoked with exports/module/require/etc. as its formal parameters. This gives genuine, JS-spec-guaranteed, function-local variable scoping — two different modules' top-level const log = ... can never collide, because each is scoped to a separate function call, full stop. (See also upstream issue nodyn/jvm-npm#37, which documents this exact mechanism and a portability caveat with older Rhino — relevant context, though not the same bug.)

This repo's rewritten jvm-npm.js (_.Module._load):

_load(file) {
    return NativeRequire.require(file);
},

The Function-wrapper mechanism has been removed entirely. Loading now delegates to NativeRequire.require(file) — i.e., whatever require implementation the host had installed before jvm-npm.js overwrote the global require binding (in this codebase, Rhino's own official org.mozilla.javascript.commonjs.module.Require, installed via RhinoJavaScriptEngine.initRequireBuilder()RequireBuilder().setSandboxed(true).createRequire(...)). That class uses a fundamentally different isolation mechanism — Scriptable scope objects / prototype chains — not per-call function-local scoping.

I could not fully pin down (via several hours of controlled testing against this exact bundled org-mozilla-rhino-2_0_0-SNAPSHOT.jar, both via the bare Rhino shell and by mimicking initRequireBuilder's setup) the precise internal condition under which that scope-object-based isolation fails for const/let specifically — several plausible configurations I constructed did NOT reproduce the crash standalone, suggesting the failure also depends on some detail of this codebase's exact Kotlin-level wiring (AssetAndUrlModuleSourceProvider's dual asset+filesystem resolution, and/or the specific order of initRequireBuilder()/initPrologue()/initEpilogue()) that I wasn't able to fully replicate outside the real app. Full writeup of what I tried: https://github.com/djbclark/autojs6-typescript/blob/main/examples/broken/01-redeclaration/README.md

Regardless of that remaining internal detail, the actionable finding is clear: removing jvm-npm's original Function-wrapper isolation removed a structural guarantee (collision-proof scoping) and replaced it with a mechanism that doesn't appear to provide the same guarantee for ES6 const/let — syntax that didn't exist when either jvm-npm or (likely) this Rhino fork's commonjs module support were originally designed, and that any TypeScript project compiles to by default.

Suggested fix

Either restore genuine per-module Function-wrapper isolation in Module._load (matching upstream jvm-npm's original design), or otherwise verify/ensure the Scriptable-based delegation path actually provides equivalent guaranteed isolation for const/let-declared top-level bindings across sibling required files.

Related, not duplicate

Reference

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions