enso/app/gui2/ydoc-server/languageServerSession.ts
Adam Obuchowicz de406c69fa
Automatic reconnect with Language Server. (#9691)
Fixes #8520

If the websocket is closed not by us, we automatically try to reconnect with it, and initialize the protocol again. **Restoring state (execution contexts, attached visualizations) is not part of this PR**.

It's a part of making IDE work after hibernation (or LS crash).

# Important Notes
It required somewhat heavy refactoring:
1. I decided to use an existing implementation of reconnecting websocket. Replaced (later discovered by me) our implementation.
2. The LanguageServer class now handles both reconnecting and re-initializing - that make usage of it simpler (no more `Promise<LanguageServer>` - each method will just wait for (re)connection and initialization.
3. The stuff in `net` src's module was partially moved to shared's counterpart (with tests). Merged `exponentialBackoff` implementations, which also brought me to
4. Rewriting LS client, so it returns Result instead of throwing, what is closer our desired state, and allows us using exponentialBackoff method without any wrappers.
2024-04-19 13:39:45 +00:00

732 lines
25 KiB
TypeScript

import * as json from 'lib0/json'
import * as map from 'lib0/map'
import { ObservableV2 } from 'lib0/observable'
import * as random from 'lib0/random'
import * as Y from 'yjs'
import * as Ast from '../shared/ast'
import { astCount } from '../shared/ast'
import { EnsoFileParts, combineFileParts, splitFileContents } from '../shared/ensoFile'
import { LanguageServer, computeTextChecksum } from '../shared/languageServer'
import {
Checksum,
FileEdit,
FileEventKind,
Path,
TextEdit,
response,
} from '../shared/languageServerTypes'
import { assertNever } from '../shared/util/assert'
import { Err, Ok, Result, withContext } from '../shared/util/data/result'
import { AbortScope, exponentialBackoff, printingCallbacks } from '../shared/util/net'
import ReconnectingWebSocketTransport from '../shared/util/net/ReconnectingWSTransport'
import {
DistributedProject,
ExternalId,
IdMap,
ModuleDoc,
visMetadataEquals,
type Uuid,
} from '../shared/yjsModel'
import {
applyDiffAsTextEdits,
applyDocumentUpdates,
prettyPrintDiff,
translateVisualizationFromFile,
} from './edits'
import * as fileFormat from './fileFormat'
import { deserializeIdMap, serializeIdMap } from './serialization'
import { WSSharedDoc } from './ydoc'
const SOURCE_DIR = 'src'
const EXTENSION = '.enso'
const DEBUG_LOG_SYNC = false
export class LanguageServerSession {
clientId: Uuid
indexDoc: WSSharedDoc
docs: Map<string, WSSharedDoc>
retainCount: number
url: string
ls: LanguageServer
connection: response.InitProtocolConnection | undefined
model: DistributedProject
projectRootId: Uuid | null
authoritativeModules: Map<string, ModulePersistence>
clientScope: AbortScope
constructor(url: string) {
this.clientScope = new AbortScope()
this.clientId = random.uuidv4() as Uuid
this.docs = new Map()
this.retainCount = 0
this.url = url
console.log('new session with', url)
this.indexDoc = new WSSharedDoc()
this.docs.set('index', this.indexDoc)
this.model = new DistributedProject(this.indexDoc.doc)
this.projectRootId = null
this.authoritativeModules = new Map()
this.indexDoc.doc.on('subdocs', (subdocs: { loaded: Set<Y.Doc> }) => {
for (const doc of subdocs.loaded) {
const name = this.model.findModuleByDocId(doc.guid)
if (!name) continue
const persistence = this.authoritativeModules.get(name)
if (!persistence) continue
}
})
this.ls = new LanguageServer(this.clientId, new ReconnectingWebSocketTransport(this.url))
this.clientScope.onAbort(() => this.ls.release())
this.setupClient()
}
static sessions = new Map<string, LanguageServerSession>()
static get(url: string): LanguageServerSession {
const session = map.setIfUndefined(
LanguageServerSession.sessions,
url,
() => new LanguageServerSession(url),
)
session.retain()
return session
}
private restartClient() {
this.ls.reconnect()
return exponentialBackoff(() => this.readInitialState())
}
private setupClient() {
this.ls.on('file/event', async (event) => {
if (DEBUG_LOG_SYNC) {
console.log('file/event', event)
}
const result = await this.handleFileEvent(event)
if (!result.ok) this.restartClient()
})
this.ls.on('text/fileModifiedOnDisk', async (event) => {
const path = event.path.segments.join('/')
const result = await exponentialBackoff(
async () => this.tryGetExistingModuleModel(event.path)?.reload() ?? Ok(),
printingCallbacks(`reloaded file '${path}'`, `reload file '${path}'`),
)
if (!result.ok) this.restartClient()
})
exponentialBackoff(
() => this.readInitialState(),
printingCallbacks('read initial state', 'read initial state'),
).then((result) => {
if (!result.ok) {
result.error.log('Could not read initial state')
exponentialBackoff(
async () => this.restartClient(),
printingCallbacks('restarted RPC client', 'restart RPC client'),
)
}
})
}
private handleFileEvent(event: { path: Path; kind: FileEventKind }): Promise<Result<void>> {
return withContext(
() => 'Handling file/event',
async () => {
const path = event.path.segments.join('/')
switch (event.kind) {
case 'Added': {
if (isSourceFile(event.path)) {
const fileInfo = await this.ls.fileInfo(event.path)
if (!fileInfo.ok) return fileInfo
if (fileInfo.value.attributes.kind.type == 'File') {
return await exponentialBackoff(
() => this.getModuleModel(event.path).open(),
printingCallbacks(`opened new file '${path}'`, `open new file '${path}'`),
)
}
}
break
}
case 'Modified': {
return await exponentialBackoff(
() => this.tryGetExistingModuleModel(event.path)?.reload() ?? Promise.resolve(Ok()),
printingCallbacks(`reloaded file '${path}'`, `reload file '${path}'`),
)
}
}
return Ok()
},
)
}
private assertProjectRoot(): asserts this is { projectRootId: Uuid } {
if (this.projectRootId == null) throw new Error('Missing project root')
}
private async readInitialState(): Promise<Result<void>> {
return await withContext(
() => 'When reading initial state',
async () => {
let moduleOpenPromises: Promise<Result<void>>[] = []
const projectRoot = (await this.ls.contentRoots).find((root) => root.type === 'Project')
if (!projectRoot) return Err('Missing project root')
this.projectRootId = projectRoot.id
const aquireResult = await this.ls.acquireReceivesTreeUpdates({
rootId: this.projectRootId,
segments: [],
})
if (!aquireResult.ok) return aquireResult
const files = await this.scanSourceFiles()
if (!files.ok) return files
moduleOpenPromises = this.indexDoc.doc.transact(
() =>
files.value.map((file) =>
this.getModuleModel(pushPathSegment(file.path, file.name)).open(),
),
this,
)
const results = await Promise.all(moduleOpenPromises)
return results.find((res) => !res.ok) ?? Ok()
},
)
}
async scanSourceFiles() {
this.assertProjectRoot()
const sourceDir: Path = { rootId: this.projectRootId, segments: [SOURCE_DIR] }
const srcModules = await this.ls.listFiles(sourceDir)
if (!srcModules.ok) return srcModules
return Ok(
srcModules.value.paths.filter(
(file) => file.type === 'File' && file.name.endsWith(EXTENSION),
),
)
}
tryGetExistingModuleModel(path: Path): ModulePersistence | undefined {
const name = pathToModuleName(path)
return this.authoritativeModules.get(name)
}
getModuleModel(path: Path): ModulePersistence {
const name = pathToModuleName(path)
return map.setIfUndefined(this.authoritativeModules, name, () => {
const wsDoc = new WSSharedDoc()
this.docs.set(wsDoc.doc.guid, wsDoc)
this.model.createUnloadedModule(name, wsDoc.doc)
const mod = new ModulePersistence(this.ls, path, wsDoc.doc)
mod.once('removed', () => {
const index = this.model.findModuleByDocId(wsDoc.doc.guid)
this.docs.delete(wsDoc.doc.guid)
this.authoritativeModules.delete(name)
if (index != null) this.model.deleteModule(index)
})
return mod
})
}
retain() {
this.retainCount += 1
}
async release(): Promise<void> {
this.retainCount -= 1
if (this.retainCount !== 0) return
const modules = this.authoritativeModules.values()
const moduleDisposePromises = Array.from(modules, (mod) => mod.dispose())
this.authoritativeModules.clear()
this.model.doc.destroy()
this.clientScope.dispose('LangueServerSession disposed.')
LanguageServerSession.sessions.delete(this.url)
await Promise.all(moduleDisposePromises)
}
getYDoc(guid: string): WSSharedDoc | undefined {
return this.docs.get(guid)
}
}
function isSourceFile(path: Path): boolean {
return (
path.segments[0] === SOURCE_DIR && path.segments[path.segments.length - 1].endsWith(EXTENSION)
)
}
function pathToModuleName(path: Path): string {
if (path.segments[0] === SOURCE_DIR) return path.segments.slice(1).join('/')
else return '//' + path.segments.join('/')
}
function pushPathSegment(path: Path, segment: string): Path {
return { rootId: path.rootId, segments: [...path.segments, segment] }
}
enum LsSyncState {
Closed,
Opening,
Synchronized,
WritingFile,
WriteError,
Reloading,
Closing,
Disposed,
}
enum LsAction {
Open,
Close,
Reload,
}
class ModulePersistence extends ObservableV2<{ removed: () => void }> {
ls: LanguageServer
path: Path
doc: ModuleDoc = new ModuleDoc(new Y.Doc())
readonly state: LsSyncState = LsSyncState.Closed
readonly lastAction = Promise.resolve()
updateToApply: Uint8Array | null = null
syncedCode: string | null = null
syncedIdMap: string | null = null
syncedMetaJson: string | null = null
syncedContent: string | null = null
syncedVersion: Checksum | null = null
syncedMeta: fileFormat.Metadata = fileFormat.tryParseMetadataOrFallback(null)
queuedAction: LsAction | null = null
cleanup = () => {}
constructor(ls: LanguageServer, path: Path, sharedDoc: Y.Doc) {
super()
this.ls = ls
this.path = path
const onRemoteUpdate = this.queueRemoteUpdate.bind(this)
const onLocalUpdate = (update: Uint8Array, origin: unknown) => {
if (origin === 'file') Y.applyUpdate(sharedDoc, update, this)
}
const onFileModified = this.handleFileModified.bind(this)
const onFileRemoved = this.handleFileRemoved.bind(this)
this.doc.ydoc.on('update', onLocalUpdate)
sharedDoc.on('update', onRemoteUpdate)
this.ls.on('text/fileModifiedOnDisk', onFileModified)
this.ls.on('file/rootRemoved', onFileRemoved)
this.cleanup = () => {
this.doc.ydoc.off('update', onLocalUpdate)
sharedDoc.off('update', onRemoteUpdate)
this.ls.off('text/fileModifiedOnDisk', onFileModified)
this.ls.off('file/rootRemoved', onFileRemoved)
}
}
private inState(...states: LsSyncState[]): boolean {
return states.includes(this.state)
}
private setState(state: LsSyncState) {
if (this.state !== LsSyncState.Disposed) {
if (DEBUG_LOG_SYNC) {
console.debug('State change:', LsSyncState[this.state], '->', LsSyncState[state])
}
// This is SAFE. `this.state` is only `readonly` to ensure that this is the only place
// where it is mutated.
// @ts-expect-error
this.state = state
if (state === LsSyncState.Synchronized) this.trySyncRemoveUpdates()
} else {
throw new Error('LsSync disposed')
}
}
private setLastAction<T>(lastAction: Promise<T>) {
// This is SAFE. `this.lastAction` is only `readonly` to ensure that this is the only place
// where it is mutated.
// @ts-expect-error
this.lastAction = lastAction.then(
() => {},
() => {},
)
return lastAction
}
/** Set the current state to the given state while the callback is running.
* Set the current state back to {@link LsSyncState.Synchronized} when the callback finishes. */
private async withState(state: LsSyncState, callback: () => void | Promise<void>): Promise<void>
private async withState(
state: LsSyncState,
callback: () => Result<void> | Promise<Result<void>>,
): Promise<Result<void>>
private async withState(
state: LsSyncState,
callback: () => void | Promise<void> | Result<void> | Promise<Result<void>>,
): Promise<Result<void> | void> {
this.setState(state)
const result = await callback()
if (result && !result.ok) return result
this.setState(LsSyncState.Synchronized)
if (result) return result
}
async open(): Promise<Result<void>> {
return await withContext(
() => `When opening module ${this.path}`,
async () => {
this.queuedAction = LsAction.Open
switch (this.state) {
case LsSyncState.Disposed:
case LsSyncState.WritingFile:
case LsSyncState.Synchronized:
case LsSyncState.WriteError:
case LsSyncState.Reloading: {
return Ok()
}
case LsSyncState.Closing: {
await this.lastAction
if (this.queuedAction === LsAction.Open) return await this.open()
return Ok()
}
case LsSyncState.Opening: {
await this.lastAction
return Ok()
}
case LsSyncState.Closed: {
await this.withState(LsSyncState.Opening, async () => {
const promise = this.ls.openTextFile(this.path)
this.setLastAction(
promise.then((res) => !res.ok && this.setState(LsSyncState.Closed)),
)
const result = await promise
if (!result.ok) return result
if (!result.value.writeCapability) {
return Err(
`Could not acquire write capability for module '${this.path.segments.join('/')}'`,
)
}
this.syncFileContents(result.value.content, result.value.currentVersion)
return Ok()
})
return Ok()
}
default: {
assertNever(this.state)
}
}
},
)
}
handleFileRemoved() {
if (this.inState(LsSyncState.Closed)) return
this.close()
}
handleFileModified() {
if (this.inState(LsSyncState.Closed)) return
}
queueRemoteUpdate(update: Uint8Array, origin: unknown) {
if (origin === this) return
if (this.updateToApply != null) {
this.updateToApply = Y.mergeUpdates([this.updateToApply, update])
} else {
this.updateToApply = update
}
this.trySyncRemoveUpdates()
}
trySyncRemoveUpdates() {
if (this.updateToApply == null) return
// apply updates to the ls-representation doc if we are already in sync with the LS.
if (!this.inState(LsSyncState.Synchronized)) return
const update = this.updateToApply
this.updateToApply = null
const syncModule = new Ast.MutableModule(this.doc.ydoc)
const moduleUpdate = syncModule.applyUpdate(update, 'remote')
if (moduleUpdate && this.syncedContent) {
const synced = splitFileContents(this.syncedContent)
const { newCode, newIdMap, newMetadata } = applyDocumentUpdates(
this.doc,
synced,
moduleUpdate,
)
this.sendLsUpdate(synced, newCode, newIdMap, newMetadata)
}
}
private sendLsUpdate(
synced: EnsoFileParts,
newCode: string | undefined,
newIdMap: IdMap | undefined,
newMetadata: fileFormat.IdeMetadata['node'] | undefined,
) {
if (this.syncedContent == null || this.syncedVersion == null) return
const code = newCode ?? synced.code
const newMetadataJson =
newMetadata &&
json.stringify({ ...this.syncedMeta, ide: { ...this.syncedMeta.ide, node: newMetadata } })
const newIdMapJson = newIdMap && serializeIdMap(newIdMap)
const newContent = combineFileParts({
code,
idMapJson: newIdMapJson ?? synced.idMapJson ?? '[]',
metadataJson: newMetadataJson ?? synced.metadataJson ?? '{}',
})
const edits: TextEdit[] = []
if (newCode) edits.push(...applyDiffAsTextEdits(0, synced.code, newCode))
if (newIdMap || newMetadata) {
const oldMetaContent = this.syncedContent.slice(synced.code.length)
const metaContent = newContent.slice(code.length)
const metaStartLine = (code.match(/\n/g) ?? []).length
edits.push(...applyDiffAsTextEdits(metaStartLine, oldMetaContent, metaContent))
}
const newVersion = computeTextChecksum(newContent)
if (DEBUG_LOG_SYNC) {
console.debug(' === changes === ')
console.debug('number of edits:', edits.length)
if (edits.length > 0) {
console.debug('version:', this.syncedVersion, '->', newVersion)
console.debug('Content diff:')
console.debug(prettyPrintDiff(this.syncedContent, newContent))
}
console.debug(' =============== ')
}
this.setState(LsSyncState.WritingFile)
const execute = newCode != null || newIdMap != null
const edit: FileEdit = { path: this.path, edits, oldVersion: this.syncedVersion, newVersion }
const apply = this.ls.applyEdit(edit, execute)
const promise = apply.then(
() => {
this.syncedContent = newContent
this.syncedVersion = newVersion
if (newMetadata) this.syncedMeta.ide.node = newMetadata
if (newCode) this.syncedCode = newCode
if (newIdMapJson) this.syncedIdMap = newIdMapJson
if (newMetadataJson) this.syncedMetaJson = newMetadataJson
this.setState(LsSyncState.Synchronized)
},
(error) => {
console.error('Could not apply edit:', error)
// Try to recover by reloading the file.
// Drop the attempted updates, since applying them have failed.
this.setState(LsSyncState.WriteError)
this.syncedContent = null
this.syncedVersion = null
this.syncedCode = null
this.syncedIdMap = null
this.syncedMetaJson = null
return this.reload()
},
)
this.setLastAction(promise)
return promise
}
private syncFileContents(content: string, version: Checksum) {
const contentsReceived = splitFileContents(content)
let unsyncedIdMap: IdMap | undefined
this.doc.ydoc.transact(() => {
const { code, idMapJson, metadataJson } = contentsReceived
const metadata = fileFormat.tryParseMetadataOrFallback(metadataJson)
const nodeMeta = Object.entries(metadata.ide.node)
let parsedSpans
const syncModule = new Ast.MutableModule(this.doc.ydoc)
if (code !== this.syncedCode) {
const syncRoot = syncModule.root()
if (syncRoot) {
const edit = syncModule.edit()
edit.getVersion(syncRoot).syncToCode(code)
const editedRoot = edit.root()
if (editedRoot instanceof Ast.BodyBlock) Ast.repair(editedRoot, edit)
syncModule.applyEdit(edit)
} else {
const { root, spans } = Ast.parseBlockWithSpans(code, syncModule)
syncModule.syncRoot(root)
parsedSpans = spans
}
}
const astRoot = syncModule.root()
if (!astRoot) return
if ((code !== this.syncedCode || idMapJson !== this.syncedIdMap) && idMapJson) {
const idMap = deserializeIdMap(idMapJson)
const spans = parsedSpans ?? Ast.print(astRoot).info
const idsAssigned = Ast.setExternalIds(syncModule, spans, idMap)
const numberOfAsts = astCount(astRoot)
const idsNotSetByMap = numberOfAsts - idsAssigned
if (idsNotSetByMap > 0) {
if (code !== this.syncedCode) {
unsyncedIdMap = Ast.spanMapToIdMap(spans)
} else {
console.warn(
`The LS sent an IdMap-only edit that is missing ${idsNotSetByMap} of our expected ASTs.`,
)
}
}
}
if (
(code !== this.syncedCode ||
idMapJson !== this.syncedIdMap ||
metadataJson !== this.syncedMetaJson) &&
nodeMeta.length !== 0
) {
const externalIdToAst = new Map<ExternalId, Ast.Ast>()
astRoot.visitRecursiveAst((ast) => {
if (!externalIdToAst.has(ast.externalId)) externalIdToAst.set(ast.externalId, ast)
})
const missing = new Set<string>()
for (const [id, meta] of nodeMeta) {
if (typeof id !== 'string') continue
const ast = externalIdToAst.get(id as ExternalId)
if (!ast) {
missing.add(id)
continue
}
const metadata = syncModule.getVersion(ast).mutableNodeMetadata()
const oldPos = metadata.get('position')
const newPos = { x: meta.position.vector[0], y: -meta.position.vector[1] }
if (oldPos?.x !== newPos.x || oldPos?.y !== newPos.y) metadata.set('position', newPos)
const oldVis = metadata.get('visualization')
const newVis = meta.visualization && translateVisualizationFromFile(meta.visualization)
if (!visMetadataEquals(newVis, oldVis)) metadata.set('visualization', newVis)
const oldColorOverride = metadata.get('colorOverride')
const newColorOverride = meta.colorOverride
if (oldColorOverride !== newColorOverride) metadata.set('colorOverride', newColorOverride)
}
}
this.syncedCode = code
this.syncedIdMap = unsyncedIdMap ? null : idMapJson
this.syncedContent = content
this.syncedVersion = version
this.syncedMeta = metadata
this.syncedMetaJson = metadataJson
}, 'file')
if (unsyncedIdMap) this.sendLsUpdate(contentsReceived, undefined, unsyncedIdMap, undefined)
}
async close() {
this.queuedAction = LsAction.Close
switch (this.state) {
case LsSyncState.Disposed:
case LsSyncState.Closed: {
return
}
case LsSyncState.Closing: {
await this.lastAction
return
}
case LsSyncState.Opening:
case LsSyncState.WritingFile:
case LsSyncState.Reloading: {
await this.lastAction
if (this.queuedAction === LsAction.Close) {
await this.close()
}
return
}
case LsSyncState.WriteError:
case LsSyncState.Synchronized: {
this.setState(LsSyncState.Closing)
const promise = this.ls.closeTextFile(this.path)
const state = this.state
this.setLastAction(promise.catch(() => this.setState(state)))
await promise
this.setState(LsSyncState.Closed)
return
}
default: {
assertNever(this.state)
}
}
}
async reload(): Promise<Result<void>> {
return await withContext(
() => `When reloading module ${this.path}`,
async () => {
this.queuedAction = LsAction.Reload
switch (this.state) {
case LsSyncState.Opening:
case LsSyncState.Disposed:
case LsSyncState.Closed:
case LsSyncState.Closing: {
return Ok()
}
case LsSyncState.Reloading: {
await this.lastAction
return Ok()
}
case LsSyncState.WritingFile: {
await this.lastAction
if (this.queuedAction === LsAction.Reload) return await this.reload()
return Ok()
}
case LsSyncState.Synchronized: {
return this.withState(LsSyncState.Reloading, async () => {
const promise = Promise.all([
this.ls.readFile(this.path),
this.ls.fileChecksum(this.path),
])
this.setLastAction(promise)
const [contents, checksum] = await promise
if (!contents.ok) return contents
if (!checksum.ok) return checksum
this.syncFileContents(contents.value.contents, checksum.value.checksum)
return Ok()
})
}
case LsSyncState.WriteError: {
return this.withState(LsSyncState.Reloading, async () => {
const path = this.path.segments.join('/')
const reloading = this.ls.closeTextFile(this.path).then(async (closing) => {
if (!closing.ok) closing.error.log('Could not close file after write error:')
return exponentialBackoff(
async () => {
const result = await this.ls.openTextFile(this.path)
if (!result.ok) return result
if (!result.value.writeCapability) {
return Err(
`Could not acquire write capability for module '${this.path.segments.join(
'/',
)}'`,
)
}
return result
},
printingCallbacks(
`opened file '${path}' for writing`,
`open file '${path}' for writing`,
),
)
})
this.setLastAction(reloading)
const result = await reloading
if (!result.ok) return result
this.syncFileContents(result.value.content, result.value.currentVersion)
return Ok()
})
}
default: {
assertNever(this.state)
}
}
},
)
}
async dispose(): Promise<void> {
this.cleanup()
const alreadyClosed = this.inState(LsSyncState.Closing, LsSyncState.Closed)
this.setState(LsSyncState.Disposed)
if (alreadyClosed) return Promise.resolve()
const closing = await this.ls.closeTextFile(this.path)
if (!closing.ok) {
closing.error.log(`Closing text file ${this.path}`)
}
}
}