Path: blob/master/src/packages/project/conat/open-files.ts
1447 views
/*1Handle opening files in a project to save/load from disk and also enable compute capabilities.23DEVELOPMENT:450. From the browser with the project opened, terminate the open-files api service:678await cc.client.conat_client.projectApi(cc.current()).system.terminate({service:'open-files'})9101112Set env variables as in a project (see api/index.ts ), then in nodejs:1314DEBUG_CONSOLE=yes DEBUG=cocalc:debug:project:conat:* node1516x = await require("@cocalc/project/conat/open-files").init(); Object.keys(x)171819[ 'openFiles', 'openDocs', 'formatter', 'terminate', 'computeServers', 'cc' ]2021> x.openFiles.getAll();2223> Object.keys(x.openDocs)2425> s = x.openDocs['z4.tasks']26// now you can directly work with the syncdoc for a given file,27// but from the perspective of the project, not the browser!28//29//3031OR:3233echo "require('@cocalc/project/conat/open-files').init(); require('@cocalc/project/bug-counter').init()" | node3435COMPUTE SERVER:3637To simulate a compute server, do exactly as above, but also set the environment38variable COMPUTE_SERVER_ID to the *global* (not project specific) id of the compute39server:4041COMPUTE_SERVER_ID=84 node4243In this case, you aso don't need to use the terminate command if the compute44server isn't actually running. To terminate a compute server open files service though:4546(TODO)474849EDITOR ACTIONS:5051Stop the open-files server and define x as above in a terminal. You can52then get the actions or store in a nodejs terminal for a particular document53as follows:5455project_id = '00847397-d6a8-4cb0-96a8-6ef64ac3e6cf'; path = '2025-03-21-100921.ipynb';56redux = require("@cocalc/jupyter/redux/app").redux; a = redux.getEditorActions(project_id, path); s = redux.getEditorStore(project_id, path); 0;575859IN A LIVE RUNNING PROJECT IN KUCALC:6061Ssh in to the project itself. You can use a terminal because that very terminal will be broken by62doing this! Then:6364/cocalc/github/src/packages/project$ . /cocalc/nvm/nvm.sh65/cocalc/github/src/packages/project$ COCALC_PROJECT_ID=... COCALC_SECRET_TOKEN="/secrets/secret-token/token" CONAT_SERVER=hub-conat node # not sure about CONAT_SERVER66Welcome to Node.js v20.19.0.67Type ".help" for more information.68> x = await require("@cocalc/project/conat/open-files").init(); Object.keys(x)69[ 'openFiles', 'openDocs', 'formatter', 'terminate', 'computeServers' ]70>717273*/7475import {76openFiles as createOpenFiles,77type OpenFiles,78type OpenFileEntry,79} from "@cocalc/project/conat/sync";80import { CONAT_OPEN_FILE_TOUCH_INTERVAL } from "@cocalc/util/conat";81import { compute_server_id, project_id } from "@cocalc/project/data";82import type { SyncDoc } from "@cocalc/sync/editor/generic/sync-doc";83import { getClient } from "@cocalc/project/client";84import { SyncString } from "@cocalc/sync/editor/string/sync";85import { SyncDB } from "@cocalc/sync/editor/db/sync";86import getLogger from "@cocalc/backend/logger";87import { reuseInFlight } from "@cocalc/util/reuse-in-flight";88import { delay } from "awaiting";89import { initJupyterRedux, removeJupyterRedux } from "@cocalc/jupyter/kernel";90import { filename_extension, original_path } from "@cocalc/util/misc";91import { createFormatterService } from "./formatter";92import { type ConatService } from "@cocalc/conat/service/service";93import { exists } from "@cocalc/backend/misc/async-utils-node";94import { map as awaitMap } from "awaiting";95import { unlink } from "fs/promises";96import { join } from "path";97import {98computeServerManager,99ComputeServerManager,100} from "@cocalc/conat/compute/manager";101import { JUPYTER_SYNCDB_EXTENSIONS } from "@cocalc/util/jupyter/names";102import { connectToConat } from "@cocalc/project/conat/connection";103104// ensure conat connection stuff is initialized105import "@cocalc/project/conat/env";106import { chdir } from "node:process";107108const logger = getLogger("project:conat:open-files");109110// we check all files we are currently managing this frequently to111// see if they exist on the filesystem:112const FILE_DELETION_CHECK_INTERVAL = 5000;113114// once we determine that a file does not exist for some reason, we115// wait this long and check *again* just to be sure. If it is still missing,116// then we close the file in memory and set the file as deleted in the117// shared openfile state.118const FILE_DELETION_GRACE_PERIOD = 2000;119120// We NEVER check a file for deletion for this long after first opening it.121// This is VERY important, since some documents, e.g., jupyter notebooks,122// can take a while to get created on disk the first time.123const FILE_DELETION_INITIAL_DELAY = 15000;124125let openFiles: OpenFiles | null = null;126let formatter: any = null;127const openDocs: { [path: string]: SyncDoc | ConatService } = {};128let computeServers: ComputeServerManager | null = null;129const openTimes: { [path: string]: number } = {};130131export function getSyncDoc(path: string): SyncDoc | undefined {132const doc = openDocs[path];133if (doc instanceof SyncString || doc instanceof SyncDB) {134return doc;135}136return undefined;137}138139export async function init() {140logger.debug("init");141142if (process.env.HOME) {143chdir(process.env.HOME);144}145146openFiles = await createOpenFiles();147148computeServers = computeServerManager({ project_id });149await computeServers.waitUntilReady();150computeServers.on("change", async ({ path, id }) => {151if (openFiles == null) {152return;153}154const entry = openFiles?.get(path);155if (entry != null) {156await handleChange({ ...entry, id });157} else {158await closeDoc(path);159}160});161162// initialize163for (const entry of openFiles.getAll()) {164handleChange(entry);165}166167// start loop to watch for and close files that aren't touched frequently:168closeIgnoredFilesLoop();169170// periodically update timestamp on backend for files we have open171touchOpenFilesLoop();172// watch if any file that is currently opened on this host gets deleted,173// and if so, mark it as such, and set it to closed.174watchForFileDeletionLoop();175176// handle changes177openFiles.on("change", (entry) => {178// we ONLY actually try to open the file here if there179// is a doctype set. When it is first being created,180// the doctype won't be the first field set, and we don't181// want to launch this until it is set.182if (entry.doctype) {183handleChange(entry);184}185});186187formatter = await createFormatterService({ openSyncDocs: openDocs });188189// useful for development190return {191openFiles,192openDocs,193formatter,194terminate,195computeServers,196cc: connectToConat(),197};198}199200export function terminate() {201logger.debug("terminating open-files service");202for (const path in openDocs) {203closeDoc(path);204}205openFiles?.close();206openFiles = null;207208formatter?.close();209formatter = null;210211computeServers?.close();212computeServers = null;213}214215function getCutoff(): number {216return Date.now() - 2.5 * CONAT_OPEN_FILE_TOUCH_INTERVAL;217}218219function computeServerId(path: string): number {220return computeServers?.get(path) ?? 0;221}222223async function handleChange({224path,225time,226deleted,227backend,228doctype,229id,230}: OpenFileEntry & { id?: number }) {231try {232if (id == null) {233id = computeServerId(path);234}235logger.debug("handleChange", { path, time, deleted, backend, doctype, id });236const syncDoc = openDocs[path];237const isOpenHere = syncDoc != null;238239if (id != compute_server_id) {240if (backend?.id == compute_server_id) {241// we are definitely not the backend right now.242openFiles?.setNotBackend(path, compute_server_id);243}244// only thing we should do is close it if it is open.245if (isOpenHere) {246await closeDoc(path);247}248return;249}250251if (deleted?.deleted) {252if (await exists(path)) {253// it's back254openFiles?.setNotDeleted(path);255} else {256if (isOpenHere) {257await closeDoc(path);258}259return;260}261}262263if (time != null && time >= getCutoff()) {264if (!isOpenHere) {265logger.debug("handleChange: opening", { path });266// users actively care about this file being opened HERE, but it isn't267await openDoc(path);268}269return;270}271} catch (err) {272console.trace(err);273logger.debug(`handleChange: WARNING - error opening ${path} -- ${err}`);274}275}276277function supportAutoclose(path: string): boolean {278// this feels way too "hard coded"; alternatively, maybe we make the kernel or whatever279// actually update the interest? or something else...280if (281path.endsWith("." + JUPYTER_SYNCDB_EXTENSIONS) ||282path.endsWith(".sagews") ||283path.endsWith(".term")284) {285return false;286}287return true;288}289290async function closeIgnoredFilesLoop() {291while (openFiles?.state == "connected") {292await delay(CONAT_OPEN_FILE_TOUCH_INTERVAL);293if (openFiles?.state != "connected") {294return;295}296const paths = Object.keys(openDocs);297if (paths.length == 0) {298logger.debug("closeIgnoredFiles: no paths currently open");299continue;300}301logger.debug(302"closeIgnoredFiles: checking",303paths.length,304"currently open paths...",305);306const cutoff = getCutoff();307for (const entry of openFiles.getAll()) {308if (309entry != null &&310entry.time != null &&311openDocs[entry.path] != null &&312entry.time <= cutoff &&313supportAutoclose(entry.path)314) {315logger.debug("closeIgnoredFiles: closing due to inactivity", entry);316closeDoc(entry.path);317}318}319}320}321322async function touchOpenFilesLoop() {323while (openFiles?.state == "connected" && openDocs != null) {324for (const path in openDocs) {325openFiles.setBackend(path, compute_server_id);326}327await delay(CONAT_OPEN_FILE_TOUCH_INTERVAL);328}329}330331async function checkForFileDeletion(path: string) {332if (openFiles == null) {333return;334}335if (Date.now() - (openTimes[path] ?? 0) <= FILE_DELETION_INITIAL_DELAY) {336return;337}338const id = computeServerId(path);339if (id != compute_server_id) {340// not our concern341return;342}343344if (path.endsWith(".term")) {345// term files are exempt -- we don't save data in them and often346// don't actually make the hidden ones for each frame in the347// filesystem at all.348return;349}350const entry = openFiles.get(path);351if (entry == null) {352return;353}354if (entry.deleted?.deleted) {355// already set as deleted -- shouldn't still be opened356await closeDoc(entry.path);357} else {358if (!process.env.HOME) {359// too dangerous360return;361}362const fullPath = join(process.env.HOME, entry.path);363// if file doesn't exist and still doesn't exist in a while,364// mark deleted, which also causes a close.365if (await exists(fullPath)) {366return;367}368// still doesn't exist?369// We must give things a reasonable amount of time, e.g., otherwise370// creating a file (e.g., jupyter notebook) might take too long and371// we randomly think it is deleted before we even make it!372await delay(FILE_DELETION_GRACE_PERIOD);373if (await exists(fullPath)) {374return;375}376// still doesn't exist377if (openFiles != null) {378logger.debug("checkForFileDeletion: marking as deleted -- ", entry);379openFiles.setDeleted(entry.path);380await closeDoc(fullPath);381// closing a file may cause it to try to save to disk the last version,382// so we delete it if that happens.383// TODO: add an option to close everywhere to not do this, and/or make384// it not save on close if the file doesn't exist.385try {386if (await exists(fullPath)) {387await unlink(fullPath);388}389} catch {}390}391}392}393394async function watchForFileDeletionLoop() {395while (openFiles != null && openFiles.state == "connected") {396await delay(FILE_DELETION_CHECK_INTERVAL);397if (openFiles?.state != "connected") {398return;399}400const paths = Object.keys(openDocs);401if (paths.length == 0) {402// logger.debug("watchForFileDeletionLoop: no paths currently open");403continue;404}405// logger.debug(406// "watchForFileDeletionLoop: checking",407// paths.length,408// "currently open paths to see if any were deleted",409// );410await awaitMap(paths, 20, checkForFileDeletion);411}412}413414const closeDoc = reuseInFlight(async (path: string) => {415logger.debug("close", { path });416try {417const doc = openDocs[path];418if (doc == null) {419return;420}421delete openDocs[path];422delete openTimes[path];423try {424await doc.close();425} catch (err) {426logger.debug(`WARNING -- issue closing doc -- ${err}`);427openFiles?.setError(path, err);428}429} finally {430if (openDocs[path] == null) {431openFiles?.setNotBackend(path, compute_server_id);432}433}434});435436const openDoc = reuseInFlight(async (path: string) => {437logger.debug("openDoc", { path });438try {439const doc = openDocs[path];440if (doc != null) {441return;442}443openTimes[path] = Date.now();444445if (path.endsWith(".term")) {446// terminals are handled directly by the project api -- also since447// doctype probably not set for them, they won't end up here.448// (this could change though, e.g., we might use doctype to449// set the terminal command).450return;451}452453const client = getClient();454let doctype: any = openFiles?.get(path)?.doctype;455logger.debug("openDoc: open files table knows ", openFiles?.get(path), {456path,457});458if (doctype == null) {459logger.debug("openDoc: doctype must be set but isn't, so bailing", {460path,461});462} else {463logger.debug("openDoc: got doctype from openFiles table", {464path,465doctype,466});467}468469let syncdoc;470if (doctype.type == "string") {471syncdoc = new SyncString({472...doctype.opts,473project_id,474path,475client,476});477} else {478syncdoc = new SyncDB({479...doctype.opts,480project_id,481path,482client,483});484}485openDocs[path] = syncdoc;486487syncdoc.on("error", (err) => {488closeDoc(path);489openFiles?.setError(path, err);490logger.debug(`syncdoc error -- ${err}`, path);491});492493// Extra backend support in some cases, e.g., Jupyter, Sage, etc.494const ext = filename_extension(path);495switch (ext) {496case JUPYTER_SYNCDB_EXTENSIONS:497logger.debug("initializing Jupyter backend for ", path);498await initJupyterRedux(syncdoc, client);499const path1 = original_path(syncdoc.get_path());500syncdoc.on("closed", async () => {501logger.debug("removing Jupyter backend for ", path1);502await removeJupyterRedux(path1, project_id);503});504break;505}506} finally {507if (openDocs[path] != null) {508openFiles?.setBackend(path, compute_server_id);509}510}511});512513514