@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.
- package/LICENSE +202 -0
- package/README.md +46 -2
- package/components/PmTerminal.css +41 -0
- package/components/PmTerminal.tsx +145 -0
- package/package.json +54 -3
- package/src/client/index.ts +5 -0
- package/src/client/jupyter-client.ts +273 -0
- package/src/client/jupyter-token.ts +87 -0
- package/src/client/messages.ts +90 -0
- package/src/client/plugin.ts +37 -0
- package/src/domain/args.ts +22 -0
- package/src/domain/config.ts +69 -0
- package/src/domain/connection.ts +37 -0
- package/src/domain/fallback-text.ts +10 -0
- package/src/domain/index.ts +26 -0
- package/src/domain/outputs.ts +89 -0
- package/src/domain/terminal-service.ts +21 -0
- package/src/node/connection.ts +45 -0
- package/src/node/declared.ts +16 -0
- package/src/node/hub.ts +68 -0
- package/src/node/presmium.ts +24 -0
- package/src/node/provider.ts +85 -0
- package/src/node/proxy.ts +141 -0
- package/src/node/terminal.ts +44 -0
- package/src/node/vite.ts +15 -0
- package/src/shared/execute.ts +70 -0
- package/src/shared/kernel-lifecycle.ts +154 -0
- package/src/shared/kernels.ts +234 -0
- package/src/shared/load-services.ts +9 -0
- package/src/shared/refusal.ts +11 -0
- package/src/shared/terminals.ts +74 -0
- package/vite.config.ts +4 -0
|
@@ -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