Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 8 additions & 3 deletions src/lib/server/collabServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const COLLAB_DB_FILENAME = 'collab.db'
export class CollabServerHandle {
/** Unique ID of this `collab-server` instance. */
readonly uuid = crypto.randomUUID()
/** Directory in which `collab-server` places its files. */
/** Directory in which `collab-server` places its ephemeral files. */
readonly workDir: string = `/tmp/collab-server-${this.uuid}/`
/** Path to the `collab-server` UDS file. */
readonly socketPath: string = path.join(this.workDir, COLLAB_SOCKET_FILENAME)
Expand Down Expand Up @@ -45,8 +45,11 @@ export class CollabServerHandle {
* The returned promise resolves when the server is ready
* and has created its UDS file.
* Throws if the server fails to start or set up.
* Repeated calls produce the same promise. */
async start() {
* Repeated calls (with any arguments) produce the same promise. */
async start(
/** Additional arguments to `bwrap` placed at the end. */
bwrapArgs: string[],
) {
if (this.starting) return this.starting
this.starting = (async () => {
await fs.mkdir(this.workDir, { recursive: true })
Expand All @@ -66,6 +69,8 @@ export class CollabServerHandle {
'--bind', this.projectDir, sandboxProjectDir,
'--bind', this.workDir, '/workspace/.collab-server',
'--chdir', '/workspace/.collab-server',
...bwrapArgs,
'--',
'/usr/bin/node',
path.join(getCollabServerDir(), 'dist', 'server.js'),
sandboxProjectDir,
Expand Down
39 changes: 26 additions & 13 deletions src/lib/server/editorSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import { getWorkspacesDir } from '@/lib/server/config'
import { getDb } from '@/lib/server/db'
import { VscodeServerHandle } from '@/lib/server/vscodeServer'
import type { Project } from '@/prisma/generated/client'
import fs from 'node:fs/promises'
import path from 'node:path'
import { EventEmitter } from 'node:stream'
import 'server-only'
import { buildPackageOverlays } from './util'

/** Admin-visible information about a running editor session. */
export interface EditorSessionInfo {
Expand Down Expand Up @@ -52,7 +54,7 @@ export class EditorSessionManager {
}

/** Find a running `collab-server` for the given project,
* or create one and store it if none are running.
* or create one and store it in {@link collabServers} if none are running.
* Does not start the server. */
private findCollabServer(project: Project, projectDir: string): CollabServerHandle {
let server = this.collabServers.get(project.id)
Expand All @@ -73,35 +75,46 @@ export class EditorSessionManager {
* Returns the path to the corresponding VSCode `iframe`. */
async ensureSession(viewer: User, owner: User, project: Project): Promise<string> {
const projectSessions = this.vscServers.get(project.id) ?? []
let session = projectSessions.find(s => s.viewer.id === viewer.id)
if (!session) {
let vscServer = projectSessions.find(s => s.viewer.id === viewer.id)
if (!vscServer) {
const projectDir = path.join(getWorkspacesDir(), owner.name, project.id)
try {
await fs.access(projectDir)
} catch (err) {
throw new Error(`Could not open project directory '${projectDir}': ${String(err)}`)
}
const overlayWorkDir = path.join(getWorkspacesDir(), owner.name, 'overlay-work', project.id)
const overlayArgs = await buildPackageOverlays(project.id, project.name, projectDir, overlayWorkDir)
const collabServer = this.findCollabServer(project, projectDir)
session = new VscodeServerHandle(viewer, owner, project, projectDir, collabServer.workDir)
session.addDisposable(async () => {
collabServer.addDisposable(async () => {
// collab-server is the last process to exit - remove the overlayfs workdir when it does.
await fs.rm(overlayWorkDir, { recursive: true, force: true })
})
vscServer = new VscodeServerHandle(viewer, owner, project, projectDir, collabServer.workDir)
vscServer.addDisposable(async () => {
this.vscServers.set(
project.id,
(this.vscServers.get(project.id) ?? []).filter(s => s !== session),
(this.vscServers.get(project.id) ?? []).filter(s => s !== vscServer),
)
this.vscServerEvents.emit('close', session!)
this.vscServerEvents.emit('close', vscServer!)
})
// Insertion happens in same transaction as failed lookup (before any `await`)
this.vscServers.set(project.id, [...(this.vscServers.get(project.id) ?? []), session])
this.vscServers.set(project.id, [...(this.vscServers.get(project.id) ?? []), vscServer])

await Promise.all([
collabServer.start().catch(async e => {
collabServer.start(overlayArgs).catch(async e => {
await collabServer.dispose()
throw e
}),
session.start().catch(async e => {
await session!.dispose()
vscServer.start(overlayArgs).catch(async e => {
await vscServer!.dispose()
throw e
}),
])
}

await session.start()
return session.vscodeIframePath
await vscServer.start([])
return vscServer.vscodeIframePath
}

killSession(projectId: string, sessionId: string): void {
Expand Down
50 changes: 49 additions & 1 deletion src/lib/server/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { getPackageSetsDir } from '@/lib/server/config'
import { getDb } from '@/lib/server/db'
import type { Project } from '@/prisma/generated/client'
import fs from 'node:fs/promises'
import path from 'node:path'
import 'server-only'
import type { User } from './auth'

Expand Down Expand Up @@ -32,7 +35,7 @@ export async function readProcesses(): Promise<Map<number, ProcessInfo>> {
return procs
}

/** Arguments that we pass to every bubblewrap sandbox. */
/** Arguments that we pass to every bubblewrap sandbox before any other arguments. */
export const BWRAP_ARGS =
/* prettier-ignore */ [
'--ro-bind', '/usr', '/usr',
Expand Down Expand Up @@ -64,6 +67,51 @@ export function bwrapProjectDir(projectName: string) {
return `/workspace/${projectName}/`
}

/** Build `bwrap` arguments that mount a project's package sets inside the sandbox,
* while creating the necessary directories on the host.
*
* Packages are mounted as writable overlays,
* with `<projectDir>/.lake/packages/<pkg>` (i.e., the usual directory) as the upper layer
* and `<getPackageSetsDir()>/<packageSet>/<pkg>` as the lower (read-only) layer.
* Overlayfs work directories are created as subdirectories of {@link overlayWorkDir},
* which per overlayfs requirements must be on the same filesystem as {@link projectDir}.
* {@link overlayWorkDir} should also not be a subdirectory of {@link projectDir},
* to prevent access from within the sandbox. */
export async function buildPackageOverlays(
projectId: string,
projectName: string,
projectDir: string,
overlayWorkDir: string,
): Promise<string[]> {
const packageSets = await getDb().projectPackageSet.findMany({ where: { projectId } })
const sandboxProjectDir = bwrapProjectDir(projectName)
const args: string[] = []
for (const { packageSet } of packageSets) {
const pkgSetDir = path.join(getPackageSetsDir(), packageSet)
const packagesFile = path.join(pkgSetDir, 'packages.txt')
try {
await fs.access(packagesFile)
} catch {
console.warn(`[buildPackageOverlays] failed to access ${packagesFile}`)
continue
}
const packages = (await fs.readFile(packagesFile, 'utf-8')).split('\n').filter(Boolean)
for (const pkg of packages) {
const lakePkgDir = path.join('.lake', 'packages', pkg)
const upperDir = path.join(projectDir, lakePkgDir)
const pkgWorkDir = path.join(overlayWorkDir, pkg)
await Promise.all([fs.mkdir(upperDir, { recursive: true }), fs.mkdir(pkgWorkDir, { recursive: true })])
const sandboxPkgDir = path.join(sandboxProjectDir, lakePkgDir)
// prettier-ignore
args.push(
'--overlay-src', path.join(pkgSetDir, pkg),
'--overlay', upperDir, pkgWorkDir, sandboxPkgDir,
)
}
}
return args
}

export function canAccessProject(user: User, project: Project) {
const isOwner = user.id === project.userId
return isOwner || project.isPublic
Expand Down
46 changes: 7 additions & 39 deletions src/lib/server/vscodeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ import {
getNginxConfDir,
getNginxLogDir,
getOpenVscodeServerDir,
getPackageSetsDir,
getWorkspacesDir,
isDevMode,
} from '@/lib/server/config'
import { getDb } from '@/lib/server/db'
import { BWRAP_ARGS, bwrapProjectDir, readProcesses } from '@/lib/server/util'
import { Project } from '@/prisma/generated/client'
import { User } from 'better-auth'
Expand Down Expand Up @@ -136,46 +134,16 @@ export class VscodeServerHandle {
await fs.writeFile(this.nginxUserRoutePath, conf)
}

/** Build `--overlay-src/--tmp-overlay` args for the associated project's package sets.
* These mount each package in the package set in the `bubblewrap` sandbox.
* Writes go to a tmpfs and are discarded when the container exits. */
private async buildOverlayArgs(sandboxProjectDir: string): Promise<string[]> {
const packageSets = await getDb().projectPackageSet.findMany({ where: { projectId: this.project.id } })
const args: string[] = []
for (const { packageSet } of packageSets) {
const setDir = path.join(getPackageSetsDir(), packageSet)
const packagesFile = path.join(setDir, 'packages.txt')
try {
await fs.access(packagesFile)
} catch {
continue
}
const packages = (await fs.readFile(packagesFile, 'utf-8')).split('\n').filter(Boolean)
for (const pkg of packages) {
await fs.mkdir(path.join(this.projectDir, '.lake', 'packages', pkg), { recursive: true })
// prettier-ignore
args.push(
'--overlay-src', path.join(setDir, pkg),
'--tmp-overlay', path.join(sandboxProjectDir, '.lake', 'packages', pkg),
)
}
}
return args
}

/** Signal the server to start.
* The returned promise resolves after the server has started listening on its port,
* and the Nginx route for {@link vscodeIframePath} has been set up.
* Repeated calls produce the same promise. */
async start() {
* Repeated calls (with any arguments) produce the same promise. */
async start(
/** Additional arguments to `bwrap` placed at the end. */
bwrapArgs: string[],
) {
if (this.starting) return this.starting
this.starting = (async () => {
try {
await fs.access(this.projectDir)
} catch (err) {
throw new Error(`Could not open project directory '${this.projectDir}': ${String(err)}`)
}

await fs.mkdir(this.socketDir, { recursive: true })
this.disposables.defer(async () => {
await fs.rm(this.socketDir, { recursive: true, force: true })
Expand All @@ -187,7 +155,6 @@ export class VscodeServerHandle {
await ensureMachineSettings(vscServerDataDir)

const sandboxProjectDir = bwrapProjectDir(this.project.name)
const overlayArgs = await this.buildOverlayArgs(sandboxProjectDir)

const devArgs = isDevMode()
? /* prettier-ignore */ [
Expand All @@ -214,7 +181,6 @@ export class VscodeServerHandle {
'--bind', this.collabWorkDir, '/workspace/.collab-server',
'--bind', this.socketDir, '/workspace/.vscode-server',
'--ro-bind-data', '3', '/workspace/.lean-workbench.json',
...overlayArgs,
'--setenv', 'HOME', '/workspace',
'--setenv', 'ELAN_HOME', getElanDir(),
'--setenv', 'PATH', `${getElanDir()}/bin:/usr/local/bin:/usr/bin:/bin`,
Expand All @@ -226,6 +192,7 @@ export class VscodeServerHandle {
'--setenv', 'GIT_CONFIG_KEY_0', 'safe.directory',
'--setenv', 'GIT_CONFIG_VALUE_0', '*',
...devArgs,
...bwrapArgs,
'--',
path.join(getOpenVscodeServerDir(), 'bin', 'code-server'),
'--socket', `/workspace/.vscode-server/${VSCODE_SOCKET_FILENAME}`,
Expand Down Expand Up @@ -271,6 +238,7 @@ export class VscodeServerHandle {
name: this.viewer.name,
image: this.viewer.image,
},
syncPatterns: [path.join(sandboxProjectDir, '**', '*')],
}),
)
await this.writeNginxUserRoute()
Expand Down
1 change: 1 addition & 0 deletions vscode-workbench/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
},
"dependencies": {
"@hocuspocus/provider": "^4.0",
"minimatch": "^10.2",
"ws": "^8.20",
"zod": "^4.4"
}
Expand Down
10 changes: 3 additions & 7 deletions vscode-workbench/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,14 @@ async function readWorkspaceMdata(log: vs.LogOutputChannel): Promise<WorkspaceMe
const raw = await fs.readFile(BWRAP_METADATA_PATH, 'utf8')
mdata = zWorkspaceMetadata.parse(JSON.parse(raw))
} catch (err) {
log.error(String(err))
log.error(`failed to parse workspace metadata: ${String(err)}`)
void vs.window.showErrorMessage('Could not detect the Lean Workbench - shutting down.')
return undefined
}

return mdata
}

function syncableDirs(): string[] {
return (vs.workspace.workspaceFolders ?? []).filter(f => f.uri.scheme === 'file').map(f => f.uri.fsPath)
}

/** Extensions that intercept text input and conflict with collaborative editing. */
const CONFLICTING_EXTENSIONS = ['vscodevim.vim', 'asvetliakov.vscode-neovim']

Expand All @@ -48,6 +44,7 @@ export async function activate(ctx: vs.ExtensionContext) {

const mdata = await readWorkspaceMdata(log)
if (!mdata) return
log.trace(`workspace metadata: ${JSON.stringify(mdata)}`)

checkInstalledExtensions()
ctx.subscriptions.push(vs.extensions.onDidChange(checkInstalledExtensions))
Expand All @@ -59,10 +56,9 @@ export async function activate(ctx: vs.ExtensionContext) {
// We apply collaborative syncing to open folders (usually just the project folder) only.
// User-specific folders such as /workspace/.vscode-remote are not synced
// (though they would be if someone opens /workspace - TODO better UX).
const bindings = new YTextBindingManager(collabServer.collabSock, syncableDirs(), log)
const bindings = new YTextBindingManager(collabServer.collabSock, mdata, log)
ctx.subscriptions.push(
bindings,
vs.workspace.onDidChangeWorkspaceFolders(() => bindings.updateSyncableDirs(syncableDirs())),
// Remote presence indicators
new RemoteSelectionDecorator(collabServer.awareness),
)
Expand Down
Loading
Loading