@lincyaw/addon-exec-jupyter 0.0.0-stage → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ import type { Kernel, ServerConnection } from '@jupyterlab/services'
2
+ import { RUNS_DIR } from '../domain/config.ts'
3
+ import { loadJupyterServices } from './load-services.ts'
4
+ import { responseStatus } from './refusal.ts'
5
+
6
+ const CONNECT_TIMEOUT_MS = 30_000
7
+
8
+ export interface JupyterKernelHandle {
9
+ id: string
10
+ dir: string
11
+ connection: Kernel.IKernelConnection
12
+ }
13
+
14
+ export type ShutdownJupyterKernel = (kernel: string, directory: string, settings: ServerConnection.ISettings) => Promise<void>
15
+
16
+ export interface JupyterKernelLifecycle {
17
+ readonly disposed: boolean
18
+ start(settings: ServerConnection.ISettings): Promise<JupyterKernelHandle>
19
+ shutdown(handle: JupyterKernelHandle): Promise<void>
20
+ dispose(): Promise<void>
21
+ }
22
+
23
+ function randomId(): string {
24
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
25
+ }
26
+
27
+ /**
28
+ * Starts kernels, each with its own directory under presmium-runs/, and shuts them down with their directory.
29
+ * Disposing shuts down every kernel, those still starting too; `shutdown` can hand the shutdown to another party.
30
+ */
31
+ export function createJupyterKernelLifecycle(options: { kernelName: string, shutdown?: ShutdownJupyterKernel }): JupyterKernelLifecycle {
32
+ const live = new Set<JupyterKernelHandle>()
33
+ const starting = new Set<Promise<JupyterKernelHandle>>()
34
+ const shuttingDown = new Map<JupyterKernelHandle, Promise<void>>()
35
+ let disposed = false
36
+ let disposing: Promise<void> | undefined
37
+
38
+ const start = (settings: ServerConnection.ISettings): Promise<JupyterKernelHandle> => {
39
+ if (disposed) return Promise.reject(new Error('the Jupyter environment has been disposed'))
40
+ const task = (async () => {
41
+ const { Drive, KernelAPI, KernelConnection } = await loadJupyterServices()
42
+ const dir = `${RUNS_DIR}/${randomId()}`
43
+ const drive = new Drive({ serverSettings: settings })
44
+ let handle: JupyterKernelHandle | undefined
45
+ await drive.save(RUNS_DIR, { type: 'directory' })
46
+ try {
47
+ await drive.save(dir, { type: 'directory' })
48
+ const model = await KernelAPI.startNew({ name: options.kernelName, path: `${dir}/kernel` } as { name: string }, settings)
49
+ const connection = new KernelConnection({ model, serverSettings: settings, handleComms: false })
50
+ handle = { id: model.id, dir, connection }
51
+ live.add(handle)
52
+ if (disposed) throw new Error('the Jupyter environment has been disposed')
53
+ await connected(connection)
54
+ if (disposed) throw new Error('the Jupyter environment has been disposed')
55
+ return handle
56
+ } catch (error) {
57
+ await (handle ? shutdown(handle) : removeDirectory(settings, dir)).catch(() => {})
58
+ throw error
59
+ }
60
+ })()
61
+ starting.add(task)
62
+ void task.then(() => starting.delete(task), () => starting.delete(task))
63
+ return task
64
+ }
65
+
66
+ const shutdown = (handle: JupyterKernelHandle): Promise<void> => {
67
+ const known = shuttingDown.get(handle)
68
+ if (known) return known
69
+ // Invoke the handoff before yielding: pagehide cannot wait for later continuations.
70
+ const cleanup = (options.shutdown ?? shutdownJupyterKernel)(handle.id, handle.dir, handle.connection.serverSettings)
71
+ handle.connection.dispose()
72
+ const task = cleanup.finally(() => {
73
+ live.delete(handle)
74
+ shuttingDown.delete(handle)
75
+ })
76
+ shuttingDown.set(handle, task)
77
+ return task
78
+ }
79
+
80
+ return {
81
+ get disposed() {
82
+ return disposed
83
+ },
84
+ start,
85
+ shutdown,
86
+ dispose() {
87
+ if (disposing) return disposing
88
+ disposed = true
89
+ // Start all known shutdowns synchronously, including keepalive handoffs.
90
+ const cleanup = [...live].map(handle => shutdown(handle))
91
+ const pending = [...starting]
92
+ disposing = (async () => {
93
+ const results = await Promise.allSettled(cleanup)
94
+ await Promise.allSettled(pending)
95
+ const failed = results.find(result => result.status === 'rejected')
96
+ if (failed?.status === 'rejected') throw failed.reason
97
+ })()
98
+ return disposing
99
+ },
100
+ }
101
+ }
102
+
103
+ /** Shuts the kernel down and deletes its directory. */
104
+ export async function shutdownJupyterKernel(kernel: string, directory: string, settings: ServerConnection.ISettings): Promise<void> {
105
+ const { ServerConnection } = await loadJupyterServices()
106
+ const response = await ServerConnection.makeRequest(`${settings.baseUrl}api/kernels/${encodeURIComponent(kernel)}`, { method: 'DELETE', keepalive: true }, settings)
107
+ if (response.status !== 204 && response.status !== 404) throw await ServerConnection.ResponseError.create(response)
108
+ await removeDirectory(settings, directory)
109
+ }
110
+
111
+ async function removeDirectory(settings: ServerConnection.ISettings, path: string): Promise<void> {
112
+ const { Drive } = await loadJupyterServices()
113
+ const drive = new Drive({ serverSettings: settings })
114
+ const remove = async (dir: string): Promise<void> => {
115
+ const model = await drive.get(dir, { content: true })
116
+ for (const item of Array.isArray(model.content) ? model.content as { path: string, type: string }[] : []) {
117
+ if (item.type === 'directory') await remove(item.path)
118
+ else await drive.delete(item.path)
119
+ }
120
+ await drive.delete(dir)
121
+ }
122
+ try {
123
+ await remove(path)
124
+ } catch (error) {
125
+ if (responseStatus(error) !== 404) throw error
126
+ } finally {
127
+ drive.dispose()
128
+ }
129
+ }
130
+
131
+ /** Why a connection to Jupyter failed, as a code a reader's text is chosen by (the message is for logs). */
132
+ export type JupyterFailure = 'jupyter-no-answer' | 'jupyter-closed'
133
+ const failure = (code: JupyterFailure, message: string) => Object.assign(new Error(message), { code })
134
+ export const failureCode = (error: unknown): JupyterFailure | undefined => {
135
+ const code = (error as { code?: unknown } | undefined)?.code
136
+ return code === 'jupyter-no-answer' || code === 'jupyter-closed' ? code : undefined
137
+ }
138
+
139
+ function connected(connection: Kernel.IKernelConnection): Promise<void> {
140
+ return new Promise((resolve, reject) => {
141
+ const timer = setTimeout(() => done(failure('jupyter-no-answer', `no answer from the kernel within ${CONNECT_TIMEOUT_MS / 1000} s`)), CONNECT_TIMEOUT_MS)
142
+ const watch = (_: unknown, status: string) => {
143
+ if (status === 'disconnected') done(failure('jupyter-closed', 'the kernel\'s WebSocket closed'))
144
+ }
145
+ const done = (error?: Error) => {
146
+ clearTimeout(timer)
147
+ connection.connectionStatusChanged.disconnect(watch)
148
+ if (error) reject(error)
149
+ else resolve()
150
+ }
151
+ connection.connectionStatusChanged.connect(watch)
152
+ connection.info.then(() => done(), (error: unknown) => done(error instanceof Error ? error : new Error(String(error))))
153
+ })
154
+ }
@@ -0,0 +1,234 @@
1
+ import { errorMessage, failedRun, languageId } from '@lincyaw/kernel'
2
+ import type { RunRequest, RunResult } from '@lincyaw/kernel'
3
+ import type { ServerConnection } from '@jupyterlab/services'
4
+ import { argsPrelude } from '../domain/args.ts'
5
+ import { DEFAULT_TIMEOUT_MS } from '../domain/config.ts'
6
+ import { OutputCollector } from '../domain/outputs.ts'
7
+ import { executeCode, writeRunFiles } from './execute.ts'
8
+ import { createJupyterKernelLifecycle, failureCode } from './kernel-lifecycle.ts'
9
+ import type { JupyterFailure, JupyterKernelHandle, ShutdownJupyterKernel } from './kernel-lifecycle.ts'
10
+ import { loadJupyterServices } from './load-services.ts'
11
+ import { isRefused } from './refusal.ts'
12
+
13
+ export interface JupyterKernelsText {
14
+ /** `error` is the failure's own words, for logs; `code` is set when the failure is one the reader's text names. */
15
+ unreachable(error: string, code?: JupyterFailure): string
16
+ refused: string
17
+ noKernel(kernel: string, known: string[]): string
18
+ wrongLanguage(kernel: string, language: string, lang: string): string
19
+ timedOut(seconds: number): string
20
+ }
21
+
22
+ export interface JupyterKernelsOptions {
23
+ executorId: string
24
+ kernelName: string
25
+ languages?: string[]
26
+ settings(): Promise<ServerConnection.ISettings>
27
+ shutdown?: ShutdownJupyterKernel
28
+ cleanupFailed?(error: unknown): void
29
+ refused?(): void
30
+ text: JupyterKernelsText
31
+ }
32
+
33
+ export interface JupyterKernelDescription {
34
+ spec: unknown
35
+ implementation: string
36
+ implementationVersion: string
37
+ language: string
38
+ languageVersion: string
39
+ }
40
+
41
+ export interface JupyterKernels {
42
+ supports(lang: string): boolean
43
+ run(request: RunRequest, signal?: AbortSignal): Promise<RunResult>
44
+ endSession(session: string): Promise<void>
45
+ describe(): Promise<JupyterKernelDescription>
46
+ probe(): Promise<string | undefined>
47
+ dispose(): Promise<void>
48
+ }
49
+
50
+ interface RunSession {
51
+ kernel?: Promise<JupyterKernelHandle>
52
+ tail: Promise<unknown>
53
+ }
54
+
55
+ /** The kernels of a Jupyter environment: one per session, kept until the session ends, and one for each run outside a session. */
56
+ export function createJupyterKernels(options: JupyterKernelsOptions): JupyterKernels {
57
+ const runSessions = new Map<string, RunSession>()
58
+ const lifecycle = createJupyterKernelLifecycle({ kernelName: options.kernelName, shutdown: options.shutdown })
59
+ let specs: Promise<{ language: string, names: string[] }> | undefined
60
+
61
+ const failedWith = (reason: string, started = Date.now()) => failedRun(options.executorId, reason, Date.now() - started)
62
+
63
+ /** A request to Jupyter failed: the credentials were refused, or the server cannot be reached. */
64
+ const failedRequest = (error: unknown, started: number): RunResult => {
65
+ if (isRefused(error)) {
66
+ options.refused?.()
67
+ return failedWith(options.text.refused, started)
68
+ }
69
+ return failedWith(options.text.unreachable(errorMessage(error), failureCode(error)), started)
70
+ }
71
+
72
+ /** The connection's settings, or the failed run when there are none (no address, refused credentials). */
73
+ const settingsOrFailedRun = async (): Promise<ServerConnection.ISettings | RunResult> => {
74
+ try {
75
+ return await options.settings()
76
+ } catch (error) {
77
+ if (isRefused(error)) {
78
+ options.refused?.()
79
+ return failedWith(options.text.refused)
80
+ }
81
+ return failedWith(errorMessage(error))
82
+ }
83
+ }
84
+
85
+ const kernelSpecs = async (settings: ServerConnection.ISettings) => {
86
+ specs ??= loadJupyterServices().then(s => s.KernelSpecAPI.getSpecs(settings)).then(models => ({
87
+ language: models.kernelspecs[options.kernelName]?.language ?? '',
88
+ names: Object.keys(models.kernelspecs),
89
+ }))
90
+ try {
91
+ return await specs
92
+ } catch (error) {
93
+ specs = undefined
94
+ throw error
95
+ }
96
+ }
97
+
98
+ const runIn = async (kernel: () => Promise<JupyterKernelHandle>, request: RunRequest, signal: AbortSignal | undefined, settings: ServerConnection.ISettings, started: number): Promise<RunResult> => {
99
+ let handle: JupyterKernelHandle
100
+ try {
101
+ signal?.throwIfAborted()
102
+ if (!options.languages) {
103
+ const { language, names } = await kernelSpecs(settings)
104
+ if (!names.includes(options.kernelName)) return failedWith(options.text.noKernel(options.kernelName, names), started)
105
+ if (language && languageId(language) !== languageId(request.lang)) return failedWith(options.text.wrongLanguage(options.kernelName, language, request.lang), started)
106
+ }
107
+ signal?.throwIfAborted()
108
+ handle = await kernel()
109
+ signal?.throwIfAborted()
110
+ if (request.files && Object.keys(request.files).length) await writeRunFiles(handle, settings, request.files)
111
+ } catch (error) {
112
+ if (signal?.aborted) return failedWith(errorMessage(signal.reason), started)
113
+ return failedRequest(error, started)
114
+ }
115
+ const timeoutMs = request.timeoutMs && request.timeoutMs > 0 ? request.timeoutMs : DEFAULT_TIMEOUT_MS
116
+ const prelude = argsPrelude(request.args, request.lang)
117
+ const collector = new OutputCollector()
118
+ const shutdown = () => lifecycle.shutdown(handle)
119
+ let status: string
120
+ let timedOut = false
121
+ try {
122
+ if (prelude) await executeCode(handle, prelude, { stdin: '', timeoutMs, signal, shutdown })
123
+ const outcome = await executeCode(handle, request.code, { stdin: request.stdin ?? '', collector, timeoutMs, signal, shutdown })
124
+ status = outcome.status
125
+ timedOut = outcome.timedOut
126
+ } catch (error) {
127
+ if (signal?.aborted) return failedWith(errorMessage(signal.reason), started)
128
+ return failedRequest(error, started)
129
+ }
130
+ const { stdout, stderr } = collector.text()
131
+ return {
132
+ ok: status === 'ok' && !timedOut,
133
+ stdout,
134
+ stderr: timedOut ? `${stderr}${options.text.timedOut(timeoutMs / 1000)}\n` : stderr,
135
+ exitCode: timedOut ? null : status === 'ok' ? 0 : 1,
136
+ durationMs: Date.now() - started,
137
+ timedOut,
138
+ source: 'live',
139
+ executorId: options.executorId,
140
+ outputs: collector.outputs,
141
+ }
142
+ }
143
+
144
+ const sessionKernel = (runSession: RunSession, settings: ServerConnection.ISettings) => async (): Promise<JupyterKernelHandle> => {
145
+ const known = runSession.kernel ? await runSession.kernel.catch(() => undefined) : undefined
146
+ if (known && !known.connection.isDisposed && known.connection.status !== 'dead') return known
147
+ runSession.kernel = lifecycle.start(settings)
148
+ runSession.kernel.catch(() => {
149
+ runSession.kernel = undefined
150
+ })
151
+ return runSession.kernel
152
+ }
153
+
154
+ const runOnce = async (request: RunRequest, signal?: AbortSignal): Promise<RunResult> => {
155
+ const started = Date.now()
156
+ const settings = await settingsOrFailedRun()
157
+ if (!('baseUrl' in settings)) return settings
158
+ let handle: JupyterKernelHandle | undefined
159
+ try {
160
+ return await runIn(async () => (handle = await lifecycle.start(settings)), request, signal, settings, started)
161
+ } finally {
162
+ if (handle) await lifecycle.shutdown(handle).catch((error) => {
163
+ if (options.cleanupFailed) options.cleanupFailed(error)
164
+ else console.warn('[presmium] Jupyter cleanup failed', error)
165
+ })
166
+ }
167
+ }
168
+
169
+ return {
170
+ supports: lang => !options.languages || options.languages.some(l => languageId(l) === languageId(lang)),
171
+ run(request, signal) {
172
+ if (signal?.aborted) return Promise.resolve(failedWith(errorMessage(signal.reason)))
173
+ if (lifecycle.disposed) return Promise.resolve(failedWith('the Jupyter environment has been disposed'))
174
+ if (!request.session) return runOnce(request, signal)
175
+ let runSession = runSessions.get(request.session)
176
+ if (!runSession) {
177
+ runSession = { tail: Promise.resolve() }
178
+ runSessions.set(request.session, runSession)
179
+ }
180
+ const own = runSession
181
+ const result = own.tail.then(async () => {
182
+ const started = Date.now()
183
+ const settings = await settingsOrFailedRun()
184
+ if (!('baseUrl' in settings)) return settings
185
+ return runIn(sessionKernel(own, settings), request, signal, settings, started)
186
+ })
187
+ own.tail = result.catch(() => undefined)
188
+ return result
189
+ },
190
+ async endSession(session) {
191
+ const known = runSessions.get(session)
192
+ if (!known) return
193
+ runSessions.delete(session)
194
+ await known.tail
195
+ const handle = await known.kernel?.catch(() => undefined)
196
+ if (handle) await lifecycle.shutdown(handle)
197
+ },
198
+ async describe() {
199
+ const settings = await options.settings()
200
+ const { ServerConnection } = await loadJupyterServices()
201
+ const response = await ServerConnection.makeRequest(`${settings.baseUrl}api/kernelspecs/${encodeURIComponent(options.kernelName)}`, {}, settings)
202
+ if (!response.ok) throw await ServerConnection.ResponseError.create(response)
203
+ const spec: unknown = await response.json()
204
+ const handle = await lifecycle.start(settings)
205
+ try {
206
+ const info = await handle.connection.info
207
+ return {
208
+ spec,
209
+ implementation: info.implementation,
210
+ implementationVersion: info.implementation_version,
211
+ language: info.language_info.name,
212
+ languageVersion: info.language_info.version,
213
+ }
214
+ } finally {
215
+ await lifecycle.shutdown(handle)
216
+ }
217
+ },
218
+ async probe() {
219
+ try {
220
+ const settings = await options.settings()
221
+ const { names } = await kernelSpecs(settings)
222
+ return names.includes(options.kernelName) ? undefined : options.text.noKernel(options.kernelName, names)
223
+ } catch (error) {
224
+ specs = undefined
225
+ return isRefused(error) ? options.text.refused : options.text.unreachable(errorMessage(error), failureCode(error))
226
+ }
227
+ },
228
+ dispose() {
229
+ const disposing = lifecycle.dispose()
230
+ runSessions.clear()
231
+ return disposing
232
+ },
233
+ }
234
+ }
@@ -0,0 +1,9 @@
1
+ type JupyterServices = typeof import('@jupyterlab/services')
2
+
3
+ let loaded: Promise<JupyterServices> | undefined
4
+
5
+ /** `@jupyterlab/services`, loaded at its first use. */
6
+ export function loadJupyterServices(): Promise<JupyterServices> {
7
+ loaded ??= import('@jupyterlab/services')
8
+ return loaded
9
+ }
@@ -0,0 +1,11 @@
1
+ /** The HTTP status of a failed request to Jupyter, when the error carries the response. */
2
+ export function responseStatus(error: unknown): number | undefined {
3
+ const response = (error as { response?: { status?: unknown } } | undefined)?.response
4
+ return typeof response?.status === 'number' ? response.status : undefined
5
+ }
6
+
7
+ /** Whether Jupyter refused the credentials (401 or 403). */
8
+ export function isRefused(error: unknown): boolean {
9
+ const status = responseStatus(error)
10
+ return status === 401 || status === 403
11
+ }
@@ -0,0 +1,74 @@
1
+ import type { ServerConnection, Terminal } from '@jupyterlab/services'
2
+ import type { TerminalEvents, TerminalSession } from '../domain/terminal-service.ts'
3
+ import { loadJupyterServices } from './load-services.ts'
4
+
5
+ export async function openTerminal(settings: ServerConnection.ISettings, events: TerminalEvents): Promise<TerminalSession> {
6
+ const { ServerConnection, TerminalManager } = await loadJupyterServices()
7
+ const url = `${settings.baseUrl}api/terminals`
8
+ const client: Terminal.ITerminalAPIClient = {
9
+ serverSettings: settings,
10
+ isAvailable: true,
11
+ async startNew() {
12
+ const response = await ServerConnection.makeRequest(url, { method: 'POST', body: '{}' }, settings)
13
+ if (response.status !== 200) throw await ServerConnection.ResponseError.create(response)
14
+ return await response.json() as Terminal.IModel
15
+ },
16
+ async listRunning() {
17
+ const response = await ServerConnection.makeRequest(url, {}, settings)
18
+ if (response.status !== 200) throw await ServerConnection.ResponseError.create(response)
19
+ return await response.json() as Terminal.IModel[]
20
+ },
21
+ async shutdown(name) {
22
+ const response = await ServerConnection.makeRequest(`${url}/${encodeURIComponent(name)}`, { method: 'DELETE' }, settings)
23
+ if (response.status !== 204 && response.status !== 404) throw await ServerConnection.ResponseError.create(response)
24
+ },
25
+ }
26
+ const manager = new TerminalManager({ serverSettings: settings, terminalAPIClient: client, standby: 'when-hidden' })
27
+ // The terminal request owns startup errors; readiness may reject during disposal.
28
+ void manager.ready.catch(() => {})
29
+ let connection: Awaited<ReturnType<typeof manager.startNew>>
30
+ try {
31
+ connection = await manager.startNew()
32
+ } catch (error) {
33
+ manager.dispose()
34
+ throw error
35
+ }
36
+ let closed = false
37
+ let dimensions: [number, number] | undefined
38
+ const resize = () => {
39
+ if (!closed && dimensions && connection.connectionStatus === 'connected') connection.send({ type: 'set_size', content: dimensions })
40
+ }
41
+ connection.connectionStatusChanged.connect(resize)
42
+ // The terminal can dispose itself before emitting its final disconnect message.
43
+ connection.disposed.connect(() => {
44
+ if (closed) return
45
+ closed = true
46
+ manager.dispose()
47
+ events.closed()
48
+ })
49
+ connection.messageReceived.connect((_, message) => {
50
+ if (message.type === 'stdout' && typeof message.content?.[0] === 'string') events.output(message.content[0])
51
+ })
52
+ return {
53
+ send: data => {
54
+ if (!closed) connection.send({ type: 'stdin', content: [data] })
55
+ },
56
+ resize: (rows, columns) => {
57
+ dimensions = [rows, columns]
58
+ resize()
59
+ },
60
+ async close() {
61
+ closed = true
62
+ try {
63
+ await connection.shutdown()
64
+ } catch {
65
+ }
66
+ manager.dispose()
67
+ },
68
+ abandon() {
69
+ closed = true
70
+ void ServerConnection.makeRequest(`${url}/${encodeURIComponent(connection.name)}`, { method: 'DELETE', keepalive: true }, settings).catch(() => {})
71
+ manager.dispose()
72
+ },
73
+ }
74
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { jupyterWsShim } from './src/node/vite.ts'
2
+
3
+ // The viewer app's build: `@jupyterlab/services` imports `ws`, which gets the package's own shim.
4
+ export default { plugins: [jupyterWsShim()] }