Restore state after reconnecting (#9755)

Fixes: #8522

Execution context is refactored slightly: now we have a single `sync` function to synchronize both visualization and execution stack.

Tested hibernation on Linux: I was able to continue my work  🎉

# Important Notes
The Refinement Notes state, that the execution mode should be set before updating the stack, but actually it makes an error on startup (changing context automatically re-executes programs, what fails if there's no frame on the stack).
This commit is contained in:
Adam Obuchowicz 2024-04-25 11:02:47 +02:00 committed by GitHub
parent 931baa4276
commit 5807c5c112
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 288 additions and 237 deletions

View File

@ -496,6 +496,7 @@ export const mockLSHandler: MockTransportData = async (method, data, transport)
case 'executionContext/push':
case 'executionContext/pop':
case 'executionContext/recompute':
case 'executionContext/setExecutionEnvironment':
case 'capability/acquire':
return {}
case 'file/list': {

View File

@ -267,7 +267,7 @@ function onRecordOnceButtonPress() {
watch(
() => projectStore.executionMode,
(modeValue) => {
projectStore.executionContext.setExecutionEnvironment(modeValue === 'live' ? 'Live' : 'Design')
projectStore.executionContext.executionEnvironment = modeValue === 'live' ? 'Live' : 'Design'
},
)

View File

@ -43,23 +43,7 @@ export function useStackNavigator() {
}
function handleBreadcrumbClick(index: number) {
const activeStack = projectStore.executionContext.desiredStack
// Number of items in desired stack should be index + 1
if (index + 1 < activeStack.length) {
for (let i = activeStack.length; i > index + 1; i--) {
projectStore.executionContext.pop()
}
} else if (index + 1 > activeStack.length) {
for (let i = activeStack.length; i <= index; i++) {
const stackItem = breadcrumbs.value[i]
if (stackItem?.type === 'LocalCall') {
const exprId = stackItem.expressionId
projectStore.executionContext.push(exprId)
} else {
console.warn('Cannot enter non-local call.')
}
}
}
projectStore.executionContext.desiredStack = breadcrumbs.value.slice(0, index + 1)
graphStore.updateState()
}

View File

@ -1,3 +1,4 @@
import { findIndexOpt } from '@/util/data/array'
import { isSome, type Opt } from '@/util/data/opt'
import { Err, Ok, type Result } from '@/util/data/result'
import { AsyncQueue, type AbortScope } from '@/util/net'
@ -6,16 +7,17 @@ import * as object from 'lib0/object'
import { ObservableV2 } from 'lib0/observable'
import * as random from 'lib0/random'
import type { LanguageServer } from 'shared/languageServer'
import type {
ContextId,
Diagnostic,
ExecutionEnvironment,
ExplicitCall,
ExpressionId,
ExpressionUpdate,
StackItem,
Uuid,
VisualizationConfiguration,
import {
stackItemsEqual,
type ContextId,
type Diagnostic,
type ExecutionEnvironment,
type ExplicitCall,
type ExpressionId,
type ExpressionUpdate,
type StackItem,
type Uuid,
type VisualizationConfiguration,
} from 'shared/languageServerTypes'
import { exponentialBackoff } from 'shared/util/net'
import type { ExternalId } from 'shared/yjsModel'
@ -46,12 +48,14 @@ function visualizationConfigEqual(
)
}
interface ExecutionContextState {
lsRpc: LanguageServer
created: boolean
visualizations: Map<Uuid, NodeVisualizationConfiguration>
stack: StackItem[]
}
type ExecutionContextState =
| { status: 'not-created' }
| {
status: 'created'
visualizations: Map<Uuid, NodeVisualizationConfiguration>
stack: StackItem[]
environment?: ExecutionEnvironment
} // | { status: 'broken'} TODO[ao] think about it
type EntryPoint = Omit<ExplicitCall, 'type'>
@ -80,146 +84,79 @@ type ExecutionContextNotification = {
* run only when the previous call is done.
*/
export class ExecutionContext extends ObservableV2<ExecutionContextNotification> {
id: ContextId = random.uuidv4() as ContextId
queue: AsyncQueue<ExecutionContextState>
taskRunning = false
visSyncScheduled = false
desiredStack: StackItem[] = reactive([])
visualizationConfigs: Map<Uuid, NodeVisualizationConfiguration> = new Map()
readonly id: ContextId = random.uuidv4() as ContextId
private queue: AsyncQueue<ExecutionContextState>
private syncScheduled = false
private clearScheduled = false
private _desiredStack: StackItem[] = reactive([])
private visualizationConfigs: Map<Uuid, NodeVisualizationConfiguration> = new Map()
private _executionEnvironment: ExecutionEnvironment = 'Design'
constructor(
lsRpc: LanguageServer,
private lsRpc: LanguageServer,
entryPoint: EntryPoint,
private abort: AbortScope,
) {
super()
this.abort.handleDispose(this)
this.queue = new AsyncQueue<ExecutionContextState>(
Promise.resolve({
lsRpc,
created: false,
visualizations: new Map(),
stack: [],
}),
)
this.lsRpc.retain()
this.queue = new AsyncQueue<ExecutionContextState>(Promise.resolve({ status: 'not-created' }))
this.registerHandlers()
this.create()
this.pushItem({ type: 'ExplicitCall', ...entryPoint })
this.recompute()
}
private async withBackoff<T>(f: () => Promise<Result<T>>, message: string): Promise<T> {
const result = await exponentialBackoff(f, {
onBeforeRetry: (error, _, delay) => {
if (this.abort.signal.aborted) return false
console.warn(`${error.message(message)}. Retrying after ${delay}ms...\n`)
},
private registerHandlers() {
this.abort.handleObserve(this.lsRpc, 'executionContext/expressionUpdates', (event) => {
if (event.contextId == this.id) this.emit('expressionUpdates', [event.updates])
})
if (result.ok) return result.value
else throw result.error
}
private syncVisualizations() {
if (this.visSyncScheduled || this.abort.signal.aborted) return
this.visSyncScheduled = true
this.queue.pushTask(async (state) => {
this.visSyncScheduled = false
if (!state.created || this.abort.signal.aborted) return state
this.emit('newVisualizationConfiguration', [new Set(this.visualizationConfigs.keys())])
const promises: Promise<void>[] = []
const attach = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() =>
state.lsRpc.attachVisualization(id, config.expressionId, {
executionContextId: this.id,
expression: config.expression,
visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions ?
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
}),
'Failed to attach visualization',
).then(() => {
state.visualizations.set(id, config)
})
}
const modify = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() =>
state.lsRpc.modifyVisualization(id, {
executionContextId: this.id,
expression: config.expression,
visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions ?
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
}),
'Failed to modify visualization',
).then(() => {
state.visualizations.set(id, config)
})
}
const detach = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() => state.lsRpc.detachVisualization(id, config.expressionId, this.id),
'Failed to detach visualization',
).then(() => {
state.visualizations.delete(id)
})
}
// Attach new and update existing visualizations.
for (const [id, config] of this.visualizationConfigs) {
const previousConfig = state.visualizations.get(id)
if (previousConfig == null) {
promises.push(attach(id, config))
} else if (!visualizationConfigEqual(previousConfig, config)) {
if (previousConfig.expressionId === config.expressionId) {
promises.push(modify(id, config))
} else {
promises.push(detach(id, previousConfig).then(() => attach(id, config)))
}
}
}
// Detach removed visualizations.
for (const [id, config] of state.visualizations) {
if (!this.visualizationConfigs.get(id)) {
promises.push(detach(id, config))
}
}
const settled = await Promise.allSettled(promises)
// Emit errors for failed requests.
const errors = settled
.map((result) => (result.status === 'rejected' ? result.reason : null))
.filter(isSome)
if (errors.length > 0) {
console.error('Failed to synchronize visualizations:', errors)
}
this.emit('visualizationsConfigured', [new Set(this.visualizationConfigs.keys())])
// State object was updated in-place in each successful promise.
return state
this.abort.handleObserve(this.lsRpc, 'executionContext/executionFailed', (event) => {
if (event.contextId == this.id) this.emit('executionFailed', [event.message])
})
this.abort.handleObserve(this.lsRpc, 'executionContext/executionComplete', (event) => {
if (event.contextId == this.id) this.emit('executionComplete', [])
})
this.abort.handleObserve(this.lsRpc, 'executionContext/executionStatus', (event) => {
if (event.contextId == this.id) this.emit('executionStatus', [event.diagnostics])
})
this.abort.handleObserve(
this.lsRpc,
'executionContext/visualizationEvaluationFailed',
(event) => {
if (event.contextId == this.id)
this.emit('visualizationEvaluationFailed', [
event.visualizationId,
event.expressionId,
event.message,
event.diagnostic,
])
},
)
this.lsRpc.on('transport/closed', () => {
// Connection closed: the created execution context is no longer available
// There is no point in any scheduled action until resynchronization
this.queue.clear()
this.syncScheduled = false
this.queue.pushTask(() => {
this.clearScheduled = false
this.sync()
return Promise.resolve({ status: 'not-created' })
})
this.clearScheduled = true
})
}
private pushItem(item: StackItem) {
this.desiredStack.push(item)
this.queue.pushTask(async (state) => {
if (!state.created) return state
await this.withBackoff(
() => state.lsRpc.pushExecutionContextItem(this.id, item),
'Failed to push item to execution context stack',
)
state.stack.push(item)
return state
})
this._desiredStack.push(item)
this.sync()
}
get desiredStack() {
return this._desiredStack
}
set desiredStack(stack: StackItem[]) {
this._desiredStack = stack
this.sync()
}
push(expressionId: ExpressionId) {
@ -227,79 +164,21 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
}
pop() {
if (this.desiredStack.length === 1) {
if (this._desiredStack.length === 1) {
console.debug('Cannot pop last item from execution context stack')
return
}
this.desiredStack.pop()
this.queue.pushTask(async (state) => {
if (!state.created) return state
if (state.stack.length === 1) {
console.debug('Cannot pop last item from execution context stack')
return state
}
await this.withBackoff(
() => state.lsRpc.popExecutionContextItem(this.id),
'Failed to pop item from execution context stack',
)
state.stack.pop()
return state
})
this._desiredStack.pop()
this.sync()
}
async setVisualization(id: Uuid, configuration: Opt<NodeVisualizationConfiguration>) {
setVisualization(id: Uuid, configuration: Opt<NodeVisualizationConfiguration>) {
if (configuration == null) {
this.visualizationConfigs.delete(id)
} else {
this.visualizationConfigs.set(id, configuration)
}
this.syncVisualizations()
}
private create() {
this.queue.pushTask(async (state) => {
if (state.created) return state
return this.withBackoff(async () => {
const result = await state.lsRpc.createExecutionContext(this.id)
if (!result.ok) return result
if (result.value.contextId !== this.id) {
return Err('Unexpected Context ID returned by the language server.')
}
state.lsRpc.retain()
return Ok({ ...state, created: true })
}, 'Failed to create execution context')
})
}
private registerHandlers() {
this.queue.pushTask(async (state) => {
this.abort.handleObserve(state.lsRpc, 'executionContext/expressionUpdates', (event) => {
if (event.contextId == this.id) this.emit('expressionUpdates', [event.updates])
})
this.abort.handleObserve(state.lsRpc, 'executionContext/executionFailed', (event) => {
if (event.contextId == this.id) this.emit('executionFailed', [event.message])
})
this.abort.handleObserve(state.lsRpc, 'executionContext/executionComplete', (event) => {
if (event.contextId == this.id) this.emit('executionComplete', [])
})
this.abort.handleObserve(state.lsRpc, 'executionContext/executionStatus', (event) => {
if (event.contextId == this.id) this.emit('executionStatus', [event.diagnostics])
})
this.abort.handleObserve(
state.lsRpc,
'executionContext/visualizationEvaluationFailed',
(event) => {
if (event.contextId == this.id)
this.emit('visualizationEvaluationFailed', [
event.visualizationId,
event.expressionId,
event.message,
event.diagnostic,
])
},
)
return state
})
this.sync()
}
recompute(
@ -307,36 +186,220 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
executionEnvironment?: ExecutionEnvironment,
) {
this.queue.pushTask(async (state) => {
if (!state.created) return state
await state.lsRpc.recomputeExecutionContext(this.id, expressionIds, executionEnvironment)
if (state.status !== 'created') {
this.sync()
return state
}
await this.lsRpc.recomputeExecutionContext(this.id, expressionIds, executionEnvironment)
return state
})
}
getStackBottom(): StackItem {
return this.desiredStack[0]!
return this._desiredStack[0]!
}
getStackTop(): StackItem {
return this.desiredStack[this.desiredStack.length - 1]!
return this._desiredStack[this._desiredStack.length - 1]!
}
setExecutionEnvironment(mode: ExecutionEnvironment) {
this.queue.pushTask(async (state) => {
await state.lsRpc.setExecutionEnvironment(this.id, mode)
return state
})
get executionEnvironment() {
return this._executionEnvironment
}
set executionEnvironment(env: ExecutionEnvironment) {
this._executionEnvironment = env
this.sync()
}
dispose() {
this.queue.pushTask(async (state) => {
if (!state.created) return state
const result = await state.lsRpc.destroyExecutionContext(this.id)
if (!result.ok) {
result.error.log('Failed to destroy execution context')
if (state.status === 'created') {
const result = await this.withBackoff(
() => this.lsRpc.destroyExecutionContext(this.id),
'Destroying execution context',
)
if (!result.ok) {
result.error.log('Failed to destroy execution context')
}
}
state.lsRpc.release()
return { ...state, created: false }
this.lsRpc.release()
return { status: 'not-created' }
})
}
private sync() {
if (this.syncScheduled || this.abort.signal.aborted) return
this.syncScheduled = true
this.queue.pushTask(this.syncTask())
}
private withBackoff<T>(f: () => Promise<Result<T>>, message: string): Promise<Result<T>> {
return exponentialBackoff(f, {
onBeforeRetry: (error, _, delay) => {
if (this.abort.signal.aborted || this.clearScheduled) return false
console.warn(`${error.message(message)}. Retrying after ${delay}ms...\n`)
},
onFailure(error) {
error.log(message)
},
})
}
private syncTask() {
return async (state: ExecutionContextState) => {
this.syncScheduled = false
if (this.abort.signal.aborted) return state
let newState = { ...state }
const create = () => {
if (newState.status === 'created') return Ok()
// if (newState.status === 'broken') {
// this.withBackoff(() => this.lsRpc.destroyExecutionContext(this.id), 'Failed to destroy broken execution context')
// }
return this.withBackoff(async () => {
const result = await this.lsRpc.createExecutionContext(this.id)
if (!result.ok) return result
if (result.value.contextId !== this.id) {
return Err('Unexpected Context ID returned by the language server.')
}
newState = { status: 'created', visualizations: new Map(), stack: [] }
return Ok()
}, 'Failed to create execution context')
}
const syncEnvironment = async () => {
const state = newState
if (state.status !== 'created')
return Err('Cannot sync execution environment when context is not created')
if (state.environment === this._executionEnvironment) return Ok()
const result = await this.lsRpc.setExecutionEnvironment(this.id, this._executionEnvironment)
if (!result.ok) return result
state.environment = this._executionEnvironment
return Ok()
}
const syncStack = async () => {
const state = newState
if (state.status !== 'created')
return Err('Cannot sync stack when execution context is not created')
const firstDifferent =
findIndexOpt(this._desiredStack, (item, index) => {
const stateStack = state.stack[index]
return stateStack == null || !stackItemsEqual(item, stateStack)
}) ?? this._desiredStack.length
for (let i = state.stack.length; i > firstDifferent; --i) {
const popResult = await this.withBackoff(
() => this.lsRpc.popExecutionContextItem(this.id),
'Failed to pop execution stack frame',
)
if (popResult.ok) state.stack.pop()
else return popResult
}
for (let i = state.stack.length; i < this._desiredStack.length; ++i) {
const newItem = this._desiredStack[i]!
const pushResult = await this.withBackoff(
() => this.lsRpc.pushExecutionContextItem(this.id, newItem),
'Failed to push execution stack frame',
)
if (pushResult.ok) state.stack.push(newItem)
else return pushResult
}
return Ok()
}
const syncVisualizations = async () => {
const state = newState
if (state.status !== 'created')
return Err('Cannot sync visualizations when execution context is not created')
const promises: Promise<void>[] = []
const attach = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() =>
this.lsRpc.attachVisualization(id, config.expressionId, {
executionContextId: this.id,
expression: config.expression,
visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions ?
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
}),
'Failed to attach visualization',
).then((result) => {
if (result.ok) state.visualizations.set(id, config)
})
}
const modify = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() =>
this.lsRpc.modifyVisualization(id, {
executionContextId: this.id,
expression: config.expression,
visualizationModule: config.visualizationModule,
...(config.positionalArgumentsExpressions ?
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
: {}),
}),
'Failed to modify visualization',
).then((result) => {
if (result.ok) state.visualizations.set(id, config)
})
}
const detach = (id: Uuid, config: NodeVisualizationConfiguration) => {
return this.withBackoff(
() => this.lsRpc.detachVisualization(id, config.expressionId, this.id),
'Failed to detach visualization',
).then((result) => {
if (result.ok) state.visualizations.delete(id)
})
}
// Attach new and update existing visualizations.
for (const [id, config] of this.visualizationConfigs) {
const previousConfig = state.visualizations.get(id)
if (previousConfig == null) {
promises.push(attach(id, config))
} else if (!visualizationConfigEqual(previousConfig, config)) {
if (previousConfig.expressionId === config.expressionId) {
promises.push(modify(id, config))
} else {
promises.push(detach(id, previousConfig).then(() => attach(id, config)))
}
}
}
// Detach removed visualizations.
for (const [id, config] of state.visualizations) {
if (!this.visualizationConfigs.get(id)) {
promises.push(detach(id, config))
}
}
const settled = await Promise.allSettled(promises)
// Emit errors for failed requests.
const errors = settled
.map((result) => (result.status === 'rejected' ? result.reason : null))
.filter(isSome)
if (errors.length > 0) {
console.error('Failed to synchronize visualizations:', errors)
}
}
const createResult = await create()
if (!createResult.ok) return newState
const syncStackResult = await syncStack()
if (!syncStackResult.ok) return newState
const syncEnvResult = await syncEnvironment()
if (!syncEnvResult.ok) return newState
this.emit('newVisualizationConfiguration', [new Set(this.visualizationConfigs.keys())])
await syncVisualizations()
this.emit('visualizationsConfigured', [
new Set(state.status === 'created' ? state.visualizations.keys() : []),
])
return newState
}
}
}

View File

@ -6,7 +6,10 @@ import type { Opt } from '@/util/data/opt'
export type NonEmptyArray<T> = [T, ...T[]]
/** An equivalent of `Array.prototype.findIndex` method, but returns null instead of -1. */
export function findIndexOpt<T>(arr: T[], pred: (elem: T) => boolean): number | null {
export function findIndexOpt<T>(
arr: T[],
pred: (elem: T, index: number) => boolean,
): number | null {
const index = arr.findIndex(pred)
return index >= 0 ? index : null
}