Preserve redo log for mutable objects

Replay it during time travel debugging
This commit is contained in:
Dmitry Vasilev
2024-01-01 18:33:46 +08:00
parent acd24fe5b7
commit 2830a160af
23 changed files with 1575 additions and 280 deletions

142
src/runtime/array.js Normal file
View File

@@ -0,0 +1,142 @@
import {Multiversion, rollback_if_needed, wrap_methods, mutate} from './multiversion.js'
function set(prop, value) {
this[prop] = value
}
export const defineMultiversionArray = window => {
// We declare class in such a weird name to have its displayed name to be
// exactly 'Array'
window.MultiversionArray = class Array extends window.Array {
constructor(initial, cxt) {
super()
this.multiversion = new Multiversion(cxt)
this.initial = [...initial]
this.redo_log = []
this.apply_initial()
}
apply_initial() {
super.length = this.initial.length
for(let i = 0; i < this.initial.length; i++) {
this[i] = this.initial[i]
}
}
static get [Symbol.species]() {
return globalThis.Array
}
}
wrap_methods(
window.MultiversionArray,
[
'at',
'concat',
'copyWithin',
'entries',
'every',
'fill',
'filter',
'find',
'findIndex',
'findLast',
'findLastIndex',
'flat',
'flatMap',
'forEach',
'includes',
'indexOf',
'join',
'keys',
'lastIndexOf',
'map',
'pop',
'push',
'reduce',
'reduceRight',
'reverse',
'shift',
'slice',
'some',
'sort',
'splice',
'toLocaleString',
'toReversed',
'toSorted',
'toSpliced',
'toString',
'unshift',
'values',
'with',
Symbol.iterator,
],
[
'copyWithin',
'fill',
'pop',
'push',
'reverse',
'shift',
'sort',
'splice',
'unshift',
]
)
}
const methods_that_return_self = new Set([
'copyWithin',
'fill',
'reverse',
'sort',
])
export function wrap_array(initial, cxt) {
const array = new cxt.window.MultiversionArray(initial, cxt)
const handler = {
get(target, prop, receiver) {
rollback_if_needed(target)
const result = target[prop]
if(
typeof(prop) == 'string'
&& isNaN(Number(prop))
&& typeof(result) == 'function'
) {
if(methods_that_return_self.has(prop)) {
// declare object with key prop for function to have a name
return {
[prop]() {
result.apply(target, arguments)
return receiver
}
}[prop]
} else {
return {
[prop]() {
return result.apply(target, arguments)
}
}[prop]
}
} else {
return result
}
},
set(obj, prop, val) {
mutate(obj, set, [prop, val])
return true
},
}
return new Proxy(array, handler)
}
export function create_array(initial, cxt, index, literals) {
const result = wrap_array(initial, cxt)
literals.set(index, result)
return result
}

View File

@@ -0,0 +1,61 @@
import {Multiversion} from './multiversion.js'
// https://stackoverflow.com/a/29018745
function binarySearch(arr, el, compare_fn) {
let m = 0;
let n = arr.length - 1;
while (m <= n) {
let k = (n + m) >> 1;
let cmp = compare_fn(el, arr[k]);
if (cmp > 0) {
m = k + 1;
} else if(cmp < 0) {
n = k - 1;
} else {
return k;
}
}
return ~m;
}
export class LetMultiversion extends Multiversion {
constructor(cxt, initial) {
super(cxt)
this.latest = initial
this.versions = [{version_number: cxt.version_counter, value: initial}]
}
rollback_if_needed() {
if(this.needs_rollback()) {
this.latest = this.get_version(this.cxt.version_counter)
}
}
get() {
this.rollback_if_needed()
return this.latest
}
set(value) {
this.rollback_if_needed()
const version_number = ++this.cxt.version_counter
if(this.is_created_during_current_expansion()) {
this.versions.push({version_number, value})
}
this.latest = value
}
get_version(version_number) {
if(version_number == null) {
throw new Error('illegal state')
}
const idx = binarySearch(this.versions, version_number, (id, el) => id - el.version_number)
if(idx >= 0) {
return this.versions[idx].value
} else if(idx == -1) {
throw new Error('illegal state')
} else {
return this.versions[-idx - 2].value
}
}
}

45
src/runtime/map.js Normal file
View File

@@ -0,0 +1,45 @@
import {Multiversion, wrap_methods, rollback_if_needed} from './multiversion.js'
export const defineMultiversionMap = window => {
// We declare class in such a weird name to have its displayed name to be
// exactly 'Map'
window.MultiversionMap = class Map extends window.Map {
constructor(initial, cxt) {
super()
this.multiversion = new Multiversion(cxt)
this.initial = new globalThis.Map(initial)
this.redo_log = []
this.apply_initial()
}
apply_initial() {
super.clear()
for(let [k,v] of this.initial) {
super.set(k,v)
}
}
get size() {
rollback_if_needed(this)
return super.size
}
}
wrap_methods(
window.MultiversionMap,
// all methods
[
'clear', 'delete', 'entries', 'forEach', 'get', 'has', 'keys', 'set', 'values',
Symbol.iterator,
],
// mutation methods
['set', 'delete', 'clear'],
)
}

View File

@@ -1,76 +1,91 @@
// https://stackoverflow.com/a/29018745
function binarySearch(arr, el, compare_fn) {
let m = 0;
let n = arr.length - 1;
while (m <= n) {
let k = (n + m) >> 1;
let cmp = compare_fn(el, arr[k]);
if (cmp > 0) {
m = k + 1;
} else if(cmp < 0) {
n = k - 1;
} else {
return k;
}
}
return ~m;
}
export class Multiversion {
constructor(cxt, initial) {
constructor(cxt) {
this.cxt = cxt
this.expand_calltree_node_number = cxt.expand_calltree_node_number
this.latest = initial
this.versions = [{version_number: cxt.version_counter, value: initial}]
this.ct_expansion_id = cxt.ct_expansion_id
}
is_created_during_current_expand() {
return this.expand_calltree_node_number == this.cxt.expand_calltree_node_number
is_created_during_current_expansion() {
return this.ct_expansion_id == this.cxt.ct_expansion_id
}
get() {
if(!this.cxt.is_expanding_calltree_node) {
return this.latest
} else {
if(this.is_created_during_current_expand()) {
return this.latest
} else {
const version_number = this.cxt.version_counter
return this.get_version(version_number)
}
}
}
get_version(version_number) {
if(version_number == null) {
throw new Error('illegal state')
}
const idx = binarySearch(this.versions, version_number, (id, el) => id - el.version_number)
if(idx >= 0) {
return this.versions[idx].value
} else if(idx == -1) {
throw new Error('illegal state')
} else {
return this.versions[-idx - 2].value
}
}
set(value) {
const version_number = ++this.cxt.version_counter
needs_rollback() {
if(this.cxt.is_expanding_calltree_node) {
if(this.is_created_during_current_expand()) {
this.latest = value
this.set_version(version_number, value)
if(this.is_created_during_current_expansion()) {
// do nothing, keep using current version
} else {
if(this.rollback_expansion_id == this.cxt.ct_expansion_id) {
// do nothing, keep using current version
// We are in the same expansion rollback was done, keep using current version
} else {
this.rollback_expansion_id = this.cxt.ct_expansion_id
return true
}
}
} else {
if(this.rollback_expansion_id != null) {
this.rollback_expansion_id = null
return true
} else {
// do nothing
}
} else {
this.latest = value
this.set_version(version_number, value)
}
}
}
set_version(version_number, value) {
this.versions.push({version_number, value})
export function rollback_if_needed(object) {
if(object.multiversion.needs_rollback()) {
// Rollback to initial value
object.apply_initial()
// Replay redo log
for(let i = 0; i < object.redo_log.length; i++) {
const log_item = object.redo_log[i]
if(log_item.version_number > object.multiversion.cxt.version_counter) {
break
}
log_item.method.apply(object, log_item.args)
}
}
}
function wrap_readonly_method(clazz, method) {
const original = clazz.__proto__.prototype[method]
clazz.prototype[method] = {
[method](){
rollback_if_needed(this)
return original.apply(this, arguments)
}
}[method]
}
export function mutate(object, method, args) {
rollback_if_needed(object)
const version_number = ++object.multiversion.cxt.version_counter
if(object.multiversion.is_created_during_current_expansion()) {
object.redo_log.push({
method,
args,
version_number,
})
}
return method.apply(object, args)
}
function wrap_mutating_method(clazz, method) {
const original = clazz.__proto__.prototype[method]
clazz.prototype[method] = {
[method]() {
return mutate(this, original, arguments)
}
}[method]
}
export function wrap_methods(clazz, all_methods, mutating_methods) {
for (let method of all_methods) {
if(mutating_methods.includes(method)) {
wrap_mutating_method(clazz, method)
} else {
wrap_readonly_method(clazz, method)
}
}
}

68
src/runtime/object.js Normal file
View File

@@ -0,0 +1,68 @@
import {Multiversion, rollback_if_needed, wrap_methods, mutate} from './multiversion.js'
export function create_object(initial, cxt, index, literals) {
const multiversion = new Multiversion(cxt)
let latest = {...initial}
const redo_log = []
function rollback_if_needed() {
if(multiversion.needs_rollback()) {
latest = {...initial}
for(let i = 0; i < redo_log.length; i++) {
const log_item = redo_log[i]
if(log_item.version_number > multiversion.cxt.version_counter) {
break
}
if(log_item.type == 'set') {
latest[log_item.prop] = log_item.value
} else if(log_item.type == 'delete') {
delete latest[log_item.prop]
} else {
throw new Error('illegal type')
}
}
}
}
const handler = {
get(target, prop, receiver) {
rollback_if_needed()
return latest[prop]
},
has(target, prop) {
rollback_if_needed()
return prop in latest
},
set(obj, prop, value) {
rollback_if_needed()
const version_number = ++multiversion.cxt.version_counter
if(multiversion.is_created_during_current_expansion()) {
redo_log.push({ type: 'set', prop, value, version_number })
}
latest[prop] = value
return true
},
ownKeys(target) {
rollback_if_needed()
return Object.keys(latest)
},
getOwnPropertyDescriptor(target, prop) {
rollback_if_needed()
return {
configurable: true,
enumerable: true,
value: latest[prop],
};
},
// TODO delete property handler
}
const result = new Proxy(initial, handler)
literals.set(index, result)
return result
}

View File

@@ -1,8 +1,9 @@
import {set_current_context} from './record_io.js'
import {Multiversion} from './multiversion.js'
// Create separate class to check value instanceof LetMultiversion
export class LetMultiversion extends Multiversion {}
import {LetMultiversion} from './let_multiversion.js'
import {defineMultiversionArray, create_array, wrap_array} from './array.js'
import {create_object} from './object.js'
import {defineMultiversionSet} from './set.js'
import {defineMultiversionMap} from './map.js'
/*
Converts generator-returning function to promise-returning function. Allows to
@@ -70,6 +71,7 @@ const do_run = function*(module_fns, cxt, io_trace){
io_trace_abort_replay,
}
defineMultiversion(cxt.window)
apply_promise_patch(cxt)
set_current_context(cxt)
@@ -85,6 +87,7 @@ const do_run = function*(module_fns, cxt, io_trace){
id: ++cxt.call_counter,
version_number: cxt.version_counter,
let_vars: {},
literals: new Map(),
}
try {
@@ -92,12 +95,15 @@ const do_run = function*(module_fns, cxt, io_trace){
const result = fn(
cxt,
calltree.let_vars,
calltree.literals,
calltree_node_by_loc.get(module),
__trace,
__trace_call,
__do_await,
__save_ct_node_for_path,
LetMultiversion,
create_array,
create_object,
)
if(result instanceof cxt.window.Promise) {
yield cxt.window.Promise.race([replay_aborted_promise, result])
@@ -193,10 +199,6 @@ export const set_record_call = cxt => {
export const do_eval_expand_calltree_node = (cxt, node) => {
cxt.is_recording_deferred_calls = false
cxt.is_expanding_calltree_node = true
cxt.expand_calltree_node_number = cxt.expand_calltree_node_number == null
? 0
: cxt.expand_calltree_node_number + 1
// Save call counter and set it to the value it had when executed 'fn' for
// the first time
@@ -208,29 +210,24 @@ export const do_eval_expand_calltree_node = (cxt, node) => {
// as node.id
: node.id - 1
const version_counter = cxt.version_counter
// Save version_counter
cxt.version_counter = node.version_number
cxt.children = null
try {
if(node.is_new) {
new node.fn(...node.args)
} else {
node.fn.apply(node.context, node.args)
}
with_version_number(cxt, node.version_number, () => {
if(node.is_new) {
new node.fn(...node.args)
} else {
node.fn.apply(node.context, node.args)
}
})
} catch(e) {
// do nothing. Exception was caught and recorded inside '__trace'
}
// Restore call counter
cxt.call_counter = call_counter
// Restore version_counter
cxt.version_counter = version_counter
cxt.is_expanding_calltree_node = false
cxt.is_recording_deferred_calls = true
const children = cxt.children
cxt.children = null
@@ -312,6 +309,9 @@ const __trace = (cxt, fn, name, argscount, __location, get_closure, has_versione
let_vars = cxt.let_vars = {}
}
// TODO only allocate map if has literals
const literals = cxt.literals = new Map()
let ok, value, error
const is_toplevel_call_copy = cxt.is_toplevel_call
@@ -343,6 +343,7 @@ const __trace = (cxt, fn, name, argscount, __location, get_closure, has_versione
version_number,
last_version_number: cxt.version_counter,
let_vars,
literals,
ok,
value,
error,
@@ -386,6 +387,47 @@ const __trace = (cxt, fn, name, argscount, __location, get_closure, has_versione
return result
}
const defineMultiversion = window => {
if(window.defineMultiversionDone) {
return
}
window.defineMultiversionDone = true
defineMultiversionArray(window)
defineMultiversionSet(window)
defineMultiversionMap(window)
}
const wrap_multiversion_value = (value, cxt) => {
// TODO use a WeakMap value => wrapper ???
if(value instanceof cxt.window.Set) {
if(!(value instanceof cxt.window.MultiversionSet)) {
return new cxt.window.MultiversionSet(value, cxt)
} else {
return value
}
}
if(value instanceof cxt.window.Map) {
if(!(value instanceof cxt.window.MultiversionMap)) {
return new cxt.window.MultiversionMap(value, cxt)
} else {
return value
}
}
if(value instanceof cxt.window.Array) {
if(!(value instanceof cxt.window.MultiversionArray)) {
return wrap_array(value, cxt)
} else {
return value
}
}
return value
}
const __trace_call = (cxt, fn, context, args, errormessage, is_new = false) => {
if(fn != null && fn.__location != null && !is_new) {
// Call will be traced, because tracing code is already embedded inside
@@ -431,7 +473,11 @@ const __trace_call = (cxt, fn, context, args, errormessage, is_new = false) => {
if(value instanceof cxt.window.Promise) {
set_record_call(cxt)
}
value = wrap_multiversion_value(value, cxt)
return value
} catch(_error) {
ok = false
error = _error
@@ -488,3 +534,28 @@ const __save_ct_node_for_path = (cxt, __calltree_node_by_loc, index, __call_id)
set_record_call(cxt)
}
}
export const with_version_number = (rt_cxt, version_number, action) => {
if(rt_cxt.logs == null) {
// check that argument is rt_cxt
throw new Error('illegal state')
}
if(version_number == null) {
throw new Error('illegal state')
}
if(rt_cxt.is_expanding_calltree_node) {
throw new Error('illegal state')
}
rt_cxt.is_expanding_calltree_node = true
const version_counter_copy = rt_cxt.version_counter
rt_cxt.version_counter = version_number
rt_cxt.ct_expansion_id = rt_cxt.ct_expansion_id == null
? 0
: rt_cxt.ct_expansion_id + 1
try {
return action()
} finally {
rt_cxt.is_expanding_calltree_node = false
rt_cxt.version_counter = version_counter_copy
}
}

44
src/runtime/set.js Normal file
View File

@@ -0,0 +1,44 @@
import {Multiversion, wrap_methods, rollback_if_needed} from './multiversion.js'
export const defineMultiversionSet = window => {
// We declare class in such a weird name to have its displayed name to be
// exactly 'Set'
window.MultiversionSet = class Set extends window.Set {
constructor(initial, cxt) {
super()
this.multiversion = new Multiversion(cxt)
this.initial = new globalThis.Set(initial)
this.redo_log = []
this.apply_initial()
}
apply_initial() {
super.clear()
for (const item of this.initial) {
super.add(item)
}
}
get size() {
rollback_if_needed(this)
return super.size
}
}
wrap_methods(
window.MultiversionSet,
// all methods
[
'has', 'add', 'delete', 'clear', 'entries', 'forEach', 'values', 'keys',
Symbol.iterator,
],
// mutation methods
['add', 'delete', 'clear'],
)
}