Skip to content

Commit

Permalink
with statements WIP 1
Browse files Browse the repository at this point in the history
  • Loading branch information
overlookmotel committed Dec 23, 2023
1 parent 647ebed commit fe33fb2
Show file tree
Hide file tree
Showing 15 changed files with 1,119 additions and 66 deletions.
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,8 +570,6 @@ NB Applications can *use* any of these within functions, just that instances of
* Unsupported: `export default Promise.resolve();` (Promise instance serialized directly)
* Unsupported: `const p = Promise.resolve(); export default function f() { return p; };` (Promise instance in outer scope of exported function)
`with (...) {...}` is also not supported where it alters the scope of a function being serialized.
### Browser code
This works in part. You can, for example, build a simple React app with Livepack.
Expand Down
8 changes: 7 additions & 1 deletion lib/init/eval.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ function evalDirect(args, filename, blockIdCounter, externalPrefixNum, evalDirec
currentBlock: undefined,
currentThisBlock: undefined,
currentSuperBlock: undefined,
currentSuperIsProto: false
currentSuperIsProto: false,
currentWithBlock: undefined
};
const tempVars = [];
let allowNewTarget = false;
Expand Down Expand Up @@ -147,6 +148,11 @@ function evalDirect(args, filename, blockIdCounter, externalPrefixNum, evalDirec
createBindingWithoutNameCheck(
block, varName, {isConst: !!isConst, isSilentConst: !!isSilentConst, argNames}
);

if (varName === 'with') {
state.currentWithBlock ||= block;
tempVars.push({value: tempVarValue, block, varName});
}
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion lib/init/tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
module.exports = createTracker;

// Imports
const addEvalFunctionsToTracker = require('./eval.js'),
const addWrapWithFunctionToTracker = require('./with.js'),
addEvalFunctionsToTracker = require('./eval.js'),
{tracker} = require('../shared/tracker.js');

// Exports
Expand All @@ -24,6 +25,7 @@ const addEvalFunctionsToTracker = require('./eval.js'),
*/
function createTracker(filename, blockIdCounter, prefixNum) {
const localTracker = (getFnInfo, getScopes) => tracker(getFnInfo, getScopes);
addWrapWithFunctionToTracker(localTracker, prefixNum);
addEvalFunctionsToTracker(localTracker, filename, blockIdCounter, prefixNum);
return localTracker;
}
132 changes: 132 additions & 0 deletions lib/init/with.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* --------------------
* livepack module
* Functions to handle `with` statements
* ------------------*/

/* eslint-disable no-restricted-properties, no-extend-native */

'use strict';

// Modules
const assert = require('simple-invariant');

// Imports
const {withBypassIsEnabled} = require('../shared/with.js'),
{INTERNAL_VAR_NAMES_PREFIX} = require('../shared/constants.js');

// Exports

module.exports = addWrapWithFunctionToTracker;

const nativeEval = eval; // eslint-disable-line no-eval

let withState;

/**
* Add `wrapWith` function to tracker.
*
* `with` statements present 2 problems for instrumentation:
*
* 1. Tracker calls need to get the values of vars outside the `with ()`,
* but `with ()` could block access to them.
* 2. `with ()` can block access to Livepack's internal vars (e.g. `livepack_tracker`),
* causing Livepack's internal mechanisms to malfunction.
*
* In both cases, it's necessary to "smuggle" the values of variables outside `with ()` to inside,
* so that properties of the `with ()` object can't interfere with them.
*
* The only solution which works in all cases is to temporarily write values to a global,
* and then retrieve them inside `with ()`.
*
* To avoid this being visible to user code, repurpose an existing global function
* `Object.prototype.__defineSetter__`.
* `__defineSetter__` is chosen because it's fairly obscure, but could use any method.
*
* `with (obj) x();` is instrumented as:
*
* ```
* with (
* livepack_tracker.wrapWith(
* obj,
* (eval, livepack_temp_3) => eval(livepack_temp_3),
* () => eval
* )
* ) with ( {}.__defineSetter__() ) x();
* ```
*
* `wrapWith()` stores the 2 functions in `withState`, and `{}.__defineSetter__()` retrieves them again.
* `__defineSetter__()` returns a Proxy which allows dynamically overriding the original `with ()`
* by using the 2 functions `wrapWith()` was called with to access any var outside the `with ()`.
* This Proxy is used as the value for a 2nd `with ()` which is inserted inside the original.
*
* @param {Function} tracker - Tracker function
* @param {number} prefixNum - Internal vars prefix num
* @returns {undefined}
*/
function addWrapWithFunctionToTracker(tracker, prefixNum) {
tracker.wrapWith = (withValue, runEval, getEval) => {
// Don't do anything if `null` or `undefined`, as it will error
if (withValue == null) return withValue;

// Throw an error if user has changed value of `Object.prototype.__defineSetter__`
const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, '__defineSetter__');
assert(
// eslint-disable-next-line no-use-before-define
descriptor?.value === shimmedDefineSetter,
'Livepack shims `Object.prototype.__defineSetter__` to instrument `with` statements.'
+ "It has been altered in user code, which prevents Livepack's correct functioning."
);

// Store state for shimmed `__defineSetter__` to retrieve
withState = [prefixNum, runEval, getEval];

// Return original `with` value
return withValue;
};
}

// Shim `Object.prototype.__defineSetter__`.
// NB: This code runs before globals are catalogued.
// Define replacement as a method, as original does not have a `prototype` property.
// 2 function params to maintain original's `.length` property.
const defineSetter = Object.prototype.__defineSetter__;
const shimmedDefineSetter = {
__defineSetter__(_x, _y) { // eslint-disable-line no-unused-vars
// If being used normally, defer to original
// eslint-disable-next-line prefer-rest-params
if (!withState) return defineSetter.apply(this, arguments);

// Is being used to smuggle values into `with ()`.
// Get state previously stored by `wrapWith()`.
const [prefixNum, runEval, getEval] = withState;
withState = undefined;

const internalVarsPrefix = `${INTERNAL_VAR_NAMES_PREFIX}${prefixNum || ''}_`;

// Return Proxy to be used as object in inner `with ()` statement.
// Proxy is transparent unless either:
// 1. Currently getting scope vars in tracker call.
// 2. Var being accessed is one of Livepack's internal vars.
// In these cases, intercept access which would otherwise hit the outer `with ()`,
// and instead use the `runEval()` function to get/set the variable outside `with ()`.
// If var being accessed is called `eval`, use `getEval()` instead.
return new Proxy(Object.create(null), {
has(target, key) {
return withBypassIsEnabled() || key.startsWith(internalVarsPrefix);
},
get(target, key) {
if (key === Symbol.unscopables) return undefined;
if (key === 'eval') return getEval();
return runEval(nativeEval, key);
},
set(target, key, value) {
// Only used for setting internal temp vars, so no need to handle if key is `v` or `eval`
const set = runEval(nativeEval, `v => ${key} = v`);
set(value);
return true;
}
});
}
}.__defineSetter__;

Object.prototype.__defineSetter__ = shimmedDefineSetter;
1 change: 1 addition & 0 deletions lib/instrument/modify.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ function modifyAst(ast, filename, isCommonJs, isStrict, sources, evalState) {
currentThisBlock: undefined,
currentSuperBlock: undefined,
currentHoistBlock: undefined,
currentWithBlock: undefined,
fileBlock: undefined,
programBlock: undefined,
currentFunction: undefined,
Expand Down
56 changes: 31 additions & 25 deletions lib/instrument/visitors/eval.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const t = require('@babel/types');

// Imports
const {activateSuperBinding} = require('./super.js'),
{activateWithBinding} = require('./with.js'),
{getOrCreateExternalVar, activateBlock, activateBinding} = require('../blocks.js'),
{createTempVarNode} = require('../internalVars.js'),
{copyLocAndComments} = require('../utils.js'),
Expand Down Expand Up @@ -108,32 +109,37 @@ function instrumentEvalCall(callNode, block, fn, isStrict, canUseSuper, superIsP

const varDefsNodes = [];
for (const [varName, binding] of block.bindings) {
// Freeze binding to prevent var being renamed when used internally in a function.
// All vars in scope need to be frozen, even if not accessible to `eval()`
// because if they're renamed, they would become accessible to `eval()` when they weren't before.
if (!binding.isFrozenName) {
binding.isFrozenName = true;
binding.trails.length = 0;
if (varName === 'with') {
// Variables accessed from within `eval()` may pass through this with block
activateWithBinding(block, state);
} else {
// Freeze binding to prevent var being renamed when used internally in a function.
// All vars in scope need to be frozen, even if not accessible to `eval()`
// because if they're renamed, they would become accessible to `eval()` when they weren't before.
if (!binding.isFrozenName) {
binding.isFrozenName = true;
binding.trails.length = 0;
}

// Skip var if it's shadowed by var with same name in higher scope
if (varNamesUsed.has(varName)) continue;

// If `eval()` is strict mode, skip vars which are illegal to use in strict mode (e.g. `package`)
// as `eval()` won't be able to access them
if (isStrict && varName !== 'this' && varName !== 'super' && isReservedWord(varName)) continue;

// Ignore `require` as it would render the function containing `eval()` un-serializable.
// Also ignore CommonJS wrapper function's `arguments` as that contains `require` too.
if ((varName === 'require' || varName === 'arguments') && block === state.fileBlock) continue;

// Ignore `super` if it's not accessible
if (varName === 'super' && !canUseSuper) continue;

varNamesUsed.add(varName);

if (varName === 'eval') evalIsLocal = true;
}

// Skip var if it's shadowed by var with same name in higher scope
if (varNamesUsed.has(varName)) continue;

// If `eval()` is strict mode, skip vars which are illegal to use in strict mode (e.g. `package`)
// as `eval()` won't be able to access them
if (isStrict && varName !== 'this' && varName !== 'super' && isReservedWord(varName)) continue;

// Ignore `require` as it would render the function containing `eval()` un-serializable.
// Also ignore CommonJS wrapper function's `arguments` as that contains `require` too.
if ((varName === 'require' || varName === 'arguments') && block === state.fileBlock) continue;

// Ignore `super` if it's not accessible
if (varName === 'super' && !canUseSuper) continue;

varNamesUsed.add(varName);

if (varName === 'eval') evalIsLocal = true;

// Create array for var `[varName, isConst, isSilentConst, argNames, tempVarValue]`.
// NB: Assignment to var called `arguments` or `eval` is illegal in strict mode.
// This is relevant as it affects whether var is flagged as mutable or not.
Expand All @@ -145,7 +151,7 @@ function instrumentEvalCall(callNode, block, fn, isStrict, canUseSuper, superIsP
while (varDefNodes.length !== 3) varDefNodes.push(null);
varDefNodes.push(t.arrayExpression(binding.argNames.map(argName => t.stringLiteral(argName))));
}
if (varName === 'super') {
if (varName === 'super' || varName === 'with') {
while (varDefNodes.length !== 4) varDefNodes.push(null);
varDefNodes.push(binding.varNode);
}
Expand Down
Loading

0 comments on commit fe33fb2

Please sign in to comment.