diff --git a/web/package.json b/web/package.json index 1ec3babd..fee144e9 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "@xterm/xterm": "^5.4.0-beta.1", "axios": "^1.7.4", "clsx": "^1.1.1", + "comlink": "^4.4.1", "connected-react-router": "^6.9.2", "copy-to-clipboard": "^3.3.1", "core-js": "^3.35.1", diff --git a/web/src/App.tsx b/web/src/App.tsx index 233f9443..cf0f7d1e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,9 +3,8 @@ import { Provider } from 'react-redux' import { ConnectedRouter } from 'connected-react-router' import { Switch, Route } from 'react-router-dom' -import { configureStore, createGoConsoleAdapter, createGoLifecycleAdapter } from './store' +import { configureStore } from './store' import { history } from '~/store/configure' -import { bootstrapGo } from '~/services/go' import config from './services/config' import { PlaygroundPage } from '~/components/pages/PlaygroundPage' import { NotFoundPage } from '~/components/pages/NotFoundPage' @@ -18,12 +17,6 @@ import './App.css' const store = configureStore() config.sync() -// Bootstrap Go and storage bridge -bootstrapGo( - createGoConsoleAdapter((a) => store.dispatch(a)), - createGoLifecycleAdapter((a) => store.dispatch(a)), -) - export const App = () => { return ( diff --git a/web/src/lib/go/common.ts b/web/src/lib/go/common.ts index 5fb3136e..fe66840c 100644 --- a/web/src/lib/go/common.ts +++ b/web/src/lib/go/common.ts @@ -22,6 +22,13 @@ export interface DebugOptions { debug?: boolean } +export const validateResponse = async (resp: Response | PromiseLike) => { + const r: Response = resp instanceof Promise ? await resp : resp + if (r.status !== 200) { + throw new Error(`Invalid HTTP response: '${r.status} ${r.statusText}' (URL: ${r.url})`) + } +} + export const instantiateStreaming = async (resp: Response | PromiseLike, importObject) => { const r: Response = resp instanceof Promise ? await resp : resp if (r.status !== 200) { diff --git a/web/src/lib/go/index.ts b/web/src/lib/go/index.ts index c9dabdc0..16166893 100644 --- a/web/src/lib/go/index.ts +++ b/web/src/lib/go/index.ts @@ -3,7 +3,7 @@ export * from './stack' export * from './types' export * from './memory' export { GoWrapper, wrapGlobal } from './wrapper/wrapper' -export { instantiateStreaming } from './common' +export { instantiateStreaming, validateResponse } from './common' export type { GoWebAssemblyInstance } from './wrapper/instance' export * as js from './pkg/syscall/js' diff --git a/web/src/services/go/foundation.ts b/web/src/lib/go/node/foundation.ts similarity index 100% rename from web/src/services/go/foundation.ts rename to web/src/lib/go/node/foundation.ts diff --git a/web/src/services/go/fs.ts b/web/src/lib/go/node/fs.ts similarity index 98% rename from web/src/services/go/fs.ts rename to web/src/lib/go/node/fs.ts index dd7009b2..3d92a2ee 100644 --- a/web/src/services/go/fs.ts +++ b/web/src/lib/go/node/fs.ts @@ -45,7 +45,7 @@ export interface IFileSystem { */ export interface IWriter { // write writes data and returns written bytes count - write: (data: Uint8Array) => number + write: (data: ArrayBuffer) => number } /** @@ -75,7 +75,7 @@ export class FileSystemWrapper { throw enosys() } - return writer.write(buf) + return writer.write(buf.buffer) } write( diff --git a/web/src/services/go/process.ts b/web/src/lib/go/node/process.ts similarity index 73% rename from web/src/services/go/process.ts rename to web/src/lib/go/node/process.ts index e5311cca..ab868640 100644 --- a/web/src/services/go/process.ts +++ b/web/src/lib/go/node/process.ts @@ -3,12 +3,17 @@ import { enosys } from './foundation' const PROCID_STUB = -1 const CWD_STUB = '/' +export type Process = Pick< + NodeJS.Process, + 'getuid' | 'getgid' | 'geteuid' | 'getegid' | 'getgroups' | 'pid' | 'ppid' | 'umask' | 'cwd' | 'chdir' +> + /** * Minimal NodeJS.Process implementation for wasm_exec.js * * Source: wasm_exec.js:87 in Go 1.17 */ -const ProcessStub = { +export const processStub: Process = { getuid() { return PROCID_STUB }, @@ -35,6 +40,4 @@ const ProcessStub = { chdir() { throw enosys() }, -} as any - -export default ProcessStub as NodeJS.Process +} diff --git a/web/src/lib/go/wrapper/interface.ts b/web/src/lib/go/wrapper/interface.ts index 01b85e7e..aba4f9ac 100644 --- a/web/src/lib/go/wrapper/interface.ts +++ b/web/src/lib/go/wrapper/interface.ts @@ -32,6 +32,8 @@ export interface ImportObject { * Introduced in Go 1.21.x. */ _gotest: Record + + [k: string]: any } export interface GoInstance { diff --git a/web/src/services/go/index.ts b/web/src/services/go/index.ts deleted file mode 100644 index 5bf4e99d..00000000 --- a/web/src/services/go/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -import '~/lib/go/wasm_exec.js' -import { FileSystemWrapper } from './fs' -import ProcessStub from './process' -import { StdioWrapper, type ConsoleLogger } from './stdio' -import { type GoWebAssemblyInstance, GoWrapper, wrapGlobal } from '~/lib/go' - -// TODO: Uncomment, when "types.ts" will be fixed -// import { Go, Global } from './go'; - -let instance: GoWrapper -let wrapper: StdioWrapper - -interface LifecycleListener { - onExit: (code: number) => void -} - -/** - * Runs Go WebAssembly binary. - * - * @param m WebAssembly instance. - * @param args Custom command line arguments. - */ -export const goRun = async (m: WebAssembly.WebAssemblyInstantiatedSource, args?: string[] | null) => { - if (!instance) { - throw new Error('Go runner instance is not initialized') - } - - wrapper.reset() - await instance.run(m.instance as GoWebAssemblyInstance, args) -} - -export const getImportObject = () => instance.importObject - -export const bootstrapGo = (logger: ConsoleLogger, listener: LifecycleListener) => { - if (instance) { - // Skip double initialization - return - } - - // Wrap Go's calls to os.Stdout and os.Stderr - wrapper = new StdioWrapper(logger) - - // global overlay - const mocks = { - mocked: true, - fs: new FileSystemWrapper(wrapper.stdoutPipe, wrapper.stderrPipe), - process: ProcessStub, - } - - // Wrap global Window and Go object to intercept console calls. - instance = new GoWrapper(new globalThis.Go(), { - globalValue: wrapGlobal(mocks, globalThis), - }) - - instance.onExit = (code: number) => { - console.log('Go: WebAssembly program finished with code:', code) - listener.onExit(code) - } -} diff --git a/web/src/services/go/stdio.ts b/web/src/services/go/stdio.ts deleted file mode 100644 index 88ba40c1..00000000 --- a/web/src/services/go/stdio.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Client-side environment for Go WASM programs - */ - -import { decoder } from './foundation' -import { type IWriter } from './fs' -import { EvalEventKind } from '../api' - -export interface ConsoleLogger { - log: (eventType: EvalEventKind, message: string) => void -} - -export class StdioWrapper { - constructor(private readonly logger: ConsoleLogger) {} - - private getWriter(kind: EvalEventKind) { - return { - write: (data: Uint8Array) => { - const msg = decoder.decode(data) - this.logger.log(kind, msg) - return data.length - }, - } - } - - reset() {} - - get stdoutPipe(): IWriter { - return this.getWriter(EvalEventKind.Stdout) - } - - get stderrPipe(): IWriter { - return this.getWriter(EvalEventKind.Stderr) - } -} diff --git a/web/src/store/dispatchers/build.ts b/web/src/store/dispatchers/build.ts deleted file mode 100644 index 6511abf6..00000000 --- a/web/src/store/dispatchers/build.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { TargetType } from '~/services/config' -import { getImportObject, goRun } from '~/services/go' -import { setTimeoutNanos, SECOND } from '~/utils/duration' -import { instantiateStreaming } from '~/lib/go' -import { buildGoTestFlags, requiresWasmEnvironment } from '~/lib/sourceutil' -import client, { type EvalEvent, EvalEventKind } from '~/services/api' -import { isProjectRequiresGoMod, goModFile, goModTemplate } from '~/services/examples' - -import { type DispatchFn, type StateProvider } from '../helpers' -import { - newAddNotificationAction, - newRemoveNotificationAction, - NotificationType, - NotificationIDs, -} from '../notifications' -import { - newErrorAction, - newLoadingAction, - newProgramFinishAction, - newProgramStartAction, - newProgramWriteAction, -} from '../actions' - -import { type Dispatcher } from './utils' -import { wrapResponseWithProgress } from '~/utils/http' -import { type BulkFileUpdatePayload, type FileUpdatePayload, WorkspaceAction } from '~/store/workspace/actions' - -/** - * Go program execution timeout in nanoseconds - */ -const runTimeoutNs = 5 * SECOND - -const lastElem = (items: T[]): T | undefined => items?.slice(-1)?.[0] - -const hasProgramTimeoutError = (events: EvalEvent[]) => { - if (events.length === 0) { - return false - } - - const { Message, Kind } = events[0] - if (Kind === 'stderr' && Message.trim() === 'timeout running program') { - const lastEvent = lastElem(events) - return (lastEvent?.Delay ?? 0) >= runTimeoutNs - } - - return false -} - -const dispatchEvalEvents = (dispatch: DispatchFn, events: EvalEvent[]) => { - // TODO: support cancellation - dispatch(newProgramStartAction()) - - if (!events?.length) { - dispatch(newProgramFinishAction()) - return - } - - // Each eval event contains time since previous event. - // Convert relative delay into absolute delay since program start. - let eventsWithDelay = events.reduce( - (accum: EvalEvent[], { Delay: delay, ...item }) => [ - ...accum, - { - ...item, - Delay: (lastElem(accum)?.Delay ?? 0) + delay, - }, - ], - [], - ) - - // Sometimes Go playground fails to detect execution timeout error and still sends all events. - // This dirty hack attempts to normalize this case. - if (hasProgramTimeoutError(eventsWithDelay)) { - eventsWithDelay = eventsWithDelay - .slice(1) - .filter(({ Delay }) => Delay <= runTimeoutNs) - .concat({ - Kind: EvalEventKind.Stderr, - Message: `Go program execution timeout exceeded (max: ${runTimeoutNs / SECOND}s)`, - Delay: runTimeoutNs, - }) - } - - // Try to guess program end time by checking last message delay. - // - // This won't work if "time.Sleep()" occurs after final message but the same - // approach used in official playground, so should be enough for us. - const programEndTime = lastElem(eventsWithDelay)?.Delay ?? 0 - - eventsWithDelay.forEach((event) => { - setTimeoutNanos(() => { - dispatch(newProgramWriteAction(event)) - }, event.Delay) - }) - - setTimeoutNanos(() => { - dispatch(newProgramFinishAction()) - }, programEndTime) -} - -const fetchWasmWithProgress = async (dispatch: DispatchFn, fileName: string) => { - try { - dispatch( - newAddNotificationAction({ - id: NotificationIDs.WASMAppDownload, - type: NotificationType.Info, - title: 'Downloading compiled program', - canDismiss: false, - progress: { - indeterminate: true, - }, - }), - ) - - let prevRafID = -1 - const rsp = await client.getArtifact(fileName) - const rspWithProgress = wrapResponseWithProgress(rsp, ({ totalBytes, currentBytes }) => { - // We want to limit number of emitted events to avoid dozens of re-renders on React side. - // If renders are too frequent, most of render queries will be dropped. - // This results in empty progress bar. - cancelAnimationFrame(prevRafID) - prevRafID = requestAnimationFrame(() => { - dispatch( - newAddNotificationAction( - { - id: NotificationIDs.WASMAppDownload, - type: NotificationType.Info, - title: 'Downloading compiled application', - canDismiss: false, - progress: { - total: totalBytes, - current: currentBytes, - }, - }, - true, - ), - ) - }) - }) - - return await instantiateStreaming(rspWithProgress, getImportObject()) - } catch (err) { - dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppDownload)) - throw err - } -} - -export const runFileDispatcher: Dispatcher = async (dispatch: DispatchFn, getState: StateProvider) => { - dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppExitError)) - dispatch(newRemoveNotificationAction(NotificationIDs.GoModMissing)) - - try { - const { - settings, - workspace, - runTarget: { target: selectedTarget, backend }, - } = getState() - - let { files, selectedFile } = workspace - if (!files || !selectedFile) { - dispatch(newErrorAction('No Go files')) - return - } - - if (isProjectRequiresGoMod(files)) { - dispatch( - newAddNotificationAction({ - id: NotificationIDs.GoModMissing, - type: NotificationType.Error, - title: 'Go.mod file is missing', - description: 'Go.mod file is required to import sub-packages.', - canDismiss: true, - actions: [ - { - key: 'ok', - label: 'Create go.mod', - primary: true, - onClick: () => { - dispatch({ - type: WorkspaceAction.ADD_FILE, - payload: [ - { - filename: goModFile, - content: goModTemplate, - }, - ], - }) - }, - }, - ], - }), - ) - return - } - - dispatch(newLoadingAction()) - if (settings.autoFormat) { - const rsp = await client.format(files, backend) - files = rsp.files - dispatch({ - type: WorkspaceAction.UPDATE_FILES, - payload: rsp.files, - }) - } - - // Force use WebAssembly for execution if source code contains go:build constraints. - let runTarget = selectedTarget - if (runTarget !== TargetType.WebAssembly && requiresWasmEnvironment(files)) { - runTarget = TargetType.WebAssembly - dispatch( - newAddNotificationAction({ - id: NotificationIDs.GoTargetSwitched, - type: NotificationType.Warning, - title: 'Go environment temporarily changed', - description: 'This program will be executed using WebAssembly as Go program contains "//go:build" tag.', - canDismiss: true, - actions: [ - { - key: 'ok', - label: 'Ok', - primary: true, - onClick: () => dispatch(newRemoveNotificationAction(NotificationIDs.GoTargetSwitched)), - }, - ], - }), - ) - } - - switch (runTarget) { - case TargetType.Server: { - // TODO: vet - const res = await client.run(files, false, backend) - dispatchEvalEvents(dispatch, res.events) - break - } - case TargetType.WebAssembly: { - const buildResponse = await client.build(files) - - const instance = await fetchWasmWithProgress(dispatch, buildResponse.fileName) - dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppDownload)) - dispatch(newProgramStartAction()) - - const argv = buildGoTestFlags(buildResponse) - goRun(instance, argv) - .then((result) => { - console.log('exit code: %d', result) - }) - .catch((err) => { - dispatch( - newAddNotificationAction({ - id: NotificationIDs.WASMAppExitError, - type: NotificationType.Error, - title: 'Failed to run WebAssembly program', - description: err.toString(), - canDismiss: true, - }), - ) - }) - .finally(() => dispatch(newProgramFinishAction())) - break - } - default: - dispatch(newErrorAction(`AppError: Unknown Go runtime type "${runTarget}"`)) - } - } catch (err: any) { - dispatch(newErrorAction(err.message)) - } -} - -export const createGoConsoleAdapter = (dispatch: DispatchFn) => ({ - log: (eventType: EvalEventKind, message: string) => { - dispatch( - newProgramWriteAction({ - Kind: eventType, - Message: message, - Delay: 0, - }), - ) - }, -}) - -export const createGoLifecycleAdapter = (dispatch: DispatchFn) => ({ - onExit: (code: number) => { - dispatch(newProgramFinishAction()) - - if (isNaN(code) || code === 0) { - return - } - - dispatch( - newAddNotificationAction({ - id: NotificationIDs.WASMAppExitError, - type: NotificationType.Warning, - title: 'Go program finished', - description: `Go program exited with non zero code: ${code}`, - canDismiss: true, - }), - ) - }, -}) diff --git a/web/src/store/dispatchers/build/dispatch.ts b/web/src/store/dispatchers/build/dispatch.ts new file mode 100644 index 00000000..44ab7bfa --- /dev/null +++ b/web/src/store/dispatchers/build/dispatch.ts @@ -0,0 +1,166 @@ +import { TargetType } from '~/services/config' +import { SECOND, setTimeoutNanos } from '~/utils/duration' +import { createStdio, GoProcess } from '~/workers/go/client' +import { buildGoTestFlags, requiresWasmEnvironment } from '~/lib/sourceutil' +import client, { type EvalEvent, EvalEventKind } from '~/services/api' +import { isProjectRequiresGoMod } from '~/services/examples' + +import { type DispatchFn, type StateProvider } from '../../helpers' +import { + newAddNotificationAction, + newRemoveNotificationAction, + NotificationIDs, + NotificationType, +} from '../../notifications' +import { + newErrorAction, + newLoadingAction, + newProgramFinishAction, + newProgramStartAction, + newProgramWriteAction, +} from '../../actions' + +import { type Dispatcher } from '../utils' +import { type BulkFileUpdatePayload, WorkspaceAction } from '~/store/workspace/actions' +import { fetchWasmWithProgress, lastElem, hasProgramTimeoutError, newStdoutHandler, runTimeoutNs} from './utils' +import { goModMissingNotification, goEnvChangedNotification, goProgramExitNotification, wasmErrorNotification } from './notifications' + +const dispatchEvalEvents = (dispatch: DispatchFn, events: EvalEvent[]) => { + // TODO: support cancellation + dispatch(newProgramStartAction()) + + if (!events?.length) { + dispatch(newProgramFinishAction()) + return + } + + // Each eval event contains time since previous event. + // Convert relative delay into absolute delay since program start. + let eventsWithDelay = events.reduce( + (accum: EvalEvent[], { Delay: delay, ...item }) => [ + ...accum, + { + ...item, + Delay: (lastElem(accum)?.Delay ?? 0) + delay, + }, + ], + [], + ) + + // Sometimes Go playground fails to detect execution timeout error and still sends all events. + // This dirty hack attempts to normalize this case. + if (hasProgramTimeoutError(eventsWithDelay)) { + eventsWithDelay = eventsWithDelay + .slice(1) + .filter(({ Delay }) => Delay <= runTimeoutNs) + .concat({ + Kind: EvalEventKind.Stderr, + Message: `Go program execution timeout exceeded (max: ${runTimeoutNs / SECOND}s)`, + Delay: runTimeoutNs, + }) + } + + // Try to guess program end time by checking last message delay. + // + // This won't work if "time.Sleep()" occurs after final message but the same + // approach used in official playground, so should be enough for us. + const programEndTime = lastElem(eventsWithDelay)?.Delay ?? 0 + + eventsWithDelay.forEach((event) => { + setTimeoutNanos(() => { + dispatch(newProgramWriteAction(event)) + }, event.Delay) + }) + + setTimeoutNanos(() => { + dispatch(newProgramFinishAction()) + }, programEndTime) +} + +export const runFileDispatcher: Dispatcher = async (dispatch: DispatchFn, getState: StateProvider) => { + dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppExitError)) + dispatch(newRemoveNotificationAction(NotificationIDs.GoModMissing)) + + try { + const { + settings, + workspace, + runTarget: { target: selectedTarget, backend }, + } = getState() + + let { files, selectedFile } = workspace + if (!files || !selectedFile) { + dispatch(newErrorAction('No Go files')) + return + } + + if (isProjectRequiresGoMod(files)) { + dispatch(goModMissingNotification(dispatch)) + return + } + + dispatch(newLoadingAction()) + if (settings.autoFormat) { + const rsp = await client.format(files, backend) + files = rsp.files + dispatch({ + type: WorkspaceAction.UPDATE_FILES, + payload: rsp.files, + }) + } + + // Force use WebAssembly for execution if source code contains go:build constraints. + let runTarget = selectedTarget + if (runTarget !== TargetType.WebAssembly && requiresWasmEnvironment(files)) { + runTarget = TargetType.WebAssembly + dispatch(goEnvChangedNotification(dispatch)) + } + + switch (runTarget) { + case TargetType.Server: { + // TODO: vet + const res = await client.run(files, false, backend) + dispatchEvalEvents(dispatch, res.events) + break + } + case TargetType.WebAssembly: { + const buildResponse = await client.build(files) + + const buff = await fetchWasmWithProgress(dispatch, buildResponse.fileName) + dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppDownload)) + dispatch(newProgramStartAction()) + + const args = buildGoTestFlags(buildResponse) + const stdio = createStdio(newStdoutHandler(dispatch)) + const proc = new GoProcess() + proc + .start(buff, stdio, { + args, + }) + .then((code) => { + if (isNaN(code) || code === 0) { + return + } + + dispatch(goProgramExitNotification(code)) + }) + .catch((err) => { + dispatch(wasmErrorNotification(err)) + }) + .finally(() => { + // This dispatch may be skipped if fired immediately after last console write. + // HACK: defer finish action to write it only after last log was written. + requestAnimationFrame(() => { + proc.terminate() + dispatch(newProgramFinishAction()) + }) + }) + break + } + default: + dispatch(newErrorAction(`AppError: Unknown Go runtime type "${runTarget}"`)) + } + } catch (err: any) { + dispatch(newErrorAction(err.message)) + } +} diff --git a/web/src/store/dispatchers/build/index.ts b/web/src/store/dispatchers/build/index.ts new file mode 100644 index 00000000..88ae37cf --- /dev/null +++ b/web/src/store/dispatchers/build/index.ts @@ -0,0 +1 @@ +export * from './dispatch' diff --git a/web/src/store/dispatchers/build/notifications.ts b/web/src/store/dispatchers/build/notifications.ts new file mode 100644 index 00000000..cdb02bcc --- /dev/null +++ b/web/src/store/dispatchers/build/notifications.ts @@ -0,0 +1,85 @@ +import { formatBytes } from '~/utils/format' +import { type FileUpdatePayload, WorkspaceAction } from '~/store/workspace/actions' +import { goModFile, goModTemplate } from '~/services/examples' +import { + type NotificationProgress, + NotificationIDs, + NotificationType, + newAddNotificationAction, + newRemoveNotificationAction +} from '../../notifications' +import type { DispatchFn } from '../../helpers' + +export const goModMissingNotification = (dispatch: DispatchFn) => newAddNotificationAction({ + id: NotificationIDs.GoModMissing, + type: NotificationType.Error, + title: 'Go.mod file is missing', + description: 'Go.mod file is required to import sub-packages.', + canDismiss: true, + actions: [ + { + key: 'ok', + label: 'Create go.mod', + primary: true, + onClick: () => { + dispatch({ + type: WorkspaceAction.ADD_FILE, + payload: [ + { + filename: goModFile, + content: goModTemplate, + }, + ], + }) + }, + }, + ], +}) + +export const goEnvChangedNotification = (dispatch: DispatchFn) => newAddNotificationAction({ + id: NotificationIDs.GoTargetSwitched, + type: NotificationType.Warning, + title: 'Go environment temporarily changed', + description: 'This program will be executed using WebAssembly as Go program contains "//go:build" tag.', + canDismiss: true, + actions: [ + { + key: 'ok', + label: 'Ok', + primary: true, + onClick: () => dispatch(newRemoveNotificationAction(NotificationIDs.GoTargetSwitched)), + }, + ], +}) + +export const goProgramExitNotification = (code: number) => newAddNotificationAction({ + id: NotificationIDs.WASMAppExitError, + type: NotificationType.Warning, + title: 'Go program finished', + description: `Go program exited with non zero code: ${code}`, + canDismiss: true, +}) + +export const wasmErrorNotification = (err: any) => newAddNotificationAction({ + id: NotificationIDs.WASMAppExitError, + type: NotificationType.Error, + title: 'Failed to run WebAssembly program', + description: err.toString(), + canDismiss: true, +}) + + +export const downloadProgressNotification = (progress?: Required>, updateOnly?: boolean) => newAddNotificationAction( + { + id: NotificationIDs.WASMAppDownload, + type: NotificationType.Info, + title: 'Downloading compiled application', + description: progress ? `${formatBytes(progress.current)} / ${formatBytes(progress.total)}` : undefined, + canDismiss: false, + progress: progress ?? { + indeterminate: true, + }, + }, + updateOnly, +) + diff --git a/web/src/store/dispatchers/build/utils.ts b/web/src/store/dispatchers/build/utils.ts new file mode 100644 index 00000000..075ee217 --- /dev/null +++ b/web/src/store/dispatchers/build/utils.ts @@ -0,0 +1,72 @@ +import { validateResponse } from '~/lib/go' +import client, { type EvalEvent, EvalEventKind } from '~/services/api' +import { SECOND } from '~/utils/duration' +import { wrapResponseWithProgress } from '~/utils/http' + +import type { DispatchFn } from '../../helpers' +import { newRemoveNotificationAction, NotificationIDs } from '../../notifications' +import { newProgramWriteAction } from '../../actions' +import { downloadProgressNotification} from './notifications' + +/** + * Go program execution timeout in nanoseconds + */ +export const runTimeoutNs = 5 * SECOND + +export const lastElem = (items: T[]): T | undefined => items?.slice(-1)?.[0] + +export const hasProgramTimeoutError = (events: EvalEvent[]) => { + if (events.length === 0) { + return false + } + + const { Message, Kind } = events[0] + if (Kind === 'stderr' && Message.trim() === 'timeout running program') { + const lastEvent = lastElem(events) + return (lastEvent?.Delay ?? 0) >= runTimeoutNs + } + + return false +} + +export const fetchWasmWithProgress = async (dispatch: DispatchFn, fileName: string) => { + try { + dispatch(downloadProgressNotification()) + + let prevRafID = -1 + const rsp = await client.getArtifact(fileName) + const rspWithProgress = wrapResponseWithProgress(rsp, ({ totalBytes, currentBytes }) => { + // We want to limit number of emitted events to avoid dozens of re-renders on React side. + // If renders are too frequent, most of all render queries will be dropped. + // This results in empty progress bar. + cancelAnimationFrame(prevRafID) + prevRafID = requestAnimationFrame(() => { + dispatch( + downloadProgressNotification({ + total: totalBytes, + current: currentBytes, + }, true) + ) + }) + }) + + await validateResponse(rspWithProgress) + return await rspWithProgress.arrayBuffer() + } catch (err) { + dispatch(newRemoveNotificationAction(NotificationIDs.WASMAppDownload)) + throw err + } +} + +const decoder = new TextDecoder() +export const newStdoutHandler = (dispatch: DispatchFn) => { + return (data: ArrayBuffer, isStderr: boolean) => { + dispatch( + newProgramWriteAction({ + Kind: isStderr ? EvalEventKind.Stderr : EvalEventKind.Stdout, + Message: decoder.decode(data), + Delay: 0, + }), + ) + } +} diff --git a/web/src/store/notifications/state.ts b/web/src/store/notifications/state.ts index d953a55f..37bebbb3 100644 --- a/web/src/store/notifications/state.ts +++ b/web/src/store/notifications/state.ts @@ -27,6 +27,29 @@ export interface NotificationAction { onClick?: () => void } +export interface NotificationProgress { + /** + * Identifies that current progress can't be determined but some activity is actually running. + */ + indeterminate?: boolean + + /** + * Total number of points to finish the operation. + * + * Used as a total value to calculate percentage. + * + * For example: total number of bytes to download. + */ + total?: number + + /** + * Current number of points processed. + * + * For example: current percentage or downloaded bytes count. + */ + current?: number +} + export interface Notification { /** * Unique notification ID. @@ -69,28 +92,7 @@ export interface Notification { /** * Progress bar information. */ - progress?: { - /** - * Identifies that current progress can't be determined but some activity is actually running. - */ - indeterminate?: boolean - - /** - * Total number of points to finish the operation. - * - * Used as a total value to calculate percentage. - * - * For example: total number of bytes to download. - */ - total?: number - - /** - * Current number of points processed. - * - * For example: current percentage or downloaded bytes count. - */ - current?: number - } + progress?: NotificationProgress /** * List of action buttons to show in a pop-up. diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts new file mode 100644 index 00000000..681c262d --- /dev/null +++ b/web/src/utils/format.ts @@ -0,0 +1,28 @@ +const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + +export const formatBytes = (bytes: number, decimals: number = 2): string => { + if (bytes === 0) return '0 Bytes' + + const k = 1024 + const i: number = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i] +} + +export const formatDuration = (milliseconds: number): string => { + const msInSecond = 1000 + const msInMinute = msInSecond * 60 + const msInHour = msInMinute * 60 + + const hours = Math.floor(milliseconds / msInHour) + const minutes = Math.floor((milliseconds % msInHour) / msInMinute) + const seconds = Math.floor((milliseconds % msInMinute) / msInSecond) + const ms = milliseconds % msInSecond + + const hoursStr = hours > 0 ? `${hours}h ` : '' + const minutesStr = minutes > 0 ? `${minutes}m ` : '' + const secondsStr = seconds > 0 ? `${seconds}s ` : '' + const msStr = ms > 0 ? `${ms}ms` : '' + + return (hoursStr + minutesStr + secondsStr + msStr).trim() +} diff --git a/web/src/workers/go/client.ts b/web/src/workers/go/client.ts new file mode 100644 index 00000000..f5ce1383 --- /dev/null +++ b/web/src/workers/go/client.ts @@ -0,0 +1,79 @@ +import * as Comlink from 'comlink' +import { formatDuration } from '~/utils/format' +import type { StartupParams, GoExecutor, WriteListener } from './types' + +const WORKER_START_TIMEOUT = 30 * 1000 + +type WriteHandler = (data: ArrayBuffer, isStderr: boolean) => void + +interface SyncStdio { + stdout: WriteListener + stderr: WriteListener +} + +export const createStdio = (handler: WriteHandler): SyncStdio => { + return { + stdout: Comlink.proxy((data) => handler(data, false)), + stderr: Comlink.proxy((data) => handler(data, true)), + } +} + +const withDeadline = async (func: () => Promise, deadline: number): Promise => { + return await new Promise((resolve, reject) => { + let deadlineExceeded = false + const tid = setTimeout(() => { + deadlineExceeded = true + reject(new Error(`Go worker start timeout exceeded (${formatDuration(deadline)})`)) + }, deadline) + + func() + .then((res) => { + if (!deadlineExceeded) { + clearTimeout(tid) + resolve(res) + } + }) + .catch((err) => { + if (!deadlineExceeded) { + clearTimeout(tid) + reject(err) + } + }) + }) +} + +/** + * Helper to start and stop Go WebAssembly programs in background. + */ +export class GoProcess { + private worker?: Worker + + /** + * Starts Go program in a separate worker and returns process exit code. + * + * @see `makeStdio` to create i/o streams handler. + * + * @param image WebAssembly program code. + * @param stdio Standard i/o streams. + * @param params Program startup params. + */ + async start(image: ArrayBuffer, stdio: SyncStdio, params?: StartupParams) { + this.worker = new Worker(new URL('./worker.ts', import.meta.url), { + type: 'module', + }) + + const proxy = Comlink.wrap(this.worker) + await withDeadline(async () => { + return await proxy.initialize(Comlink.proxy(stdio)) + }, WORKER_START_TIMEOUT) + + return await proxy.run(Comlink.transfer({ image, params }, [image])) + } + + /** + * Terminates current Go program. + */ + terminate() { + this.worker?.terminate() + } +} diff --git a/web/src/workers/go/types.ts b/web/src/workers/go/types.ts new file mode 100644 index 00000000..70371189 --- /dev/null +++ b/web/src/workers/go/types.ts @@ -0,0 +1,36 @@ +export type WriteListener = (data: ArrayBuffer) => void + +export interface Stdio { + stdout: WriteListener + stderr: WriteListener +} + +export interface StartupParams { + env?: Record + args?: string[] +} + +export interface ExecParams { + /** + * WebAssembly program binary + */ + image: ArrayBuffer + + /** + * Program execution params. + */ + params?: StartupParams +} + +export interface GoExecutor { + /** + * Attaches event listeners and initializes an executor. + * @param stdio + */ + initialize: (stdio: Stdio) => void + + /** + * Starts Go WebAssembly program and returns exit code. + */ + run: (params: ExecParams) => Promise +} diff --git a/web/src/workers/go/worker.ts b/web/src/workers/go/worker.ts new file mode 100644 index 00000000..7c64053f --- /dev/null +++ b/web/src/workers/go/worker.ts @@ -0,0 +1,49 @@ +import '~/lib/go/wasm_exec.js' +import * as Comlink from 'comlink' +import { FileSystemWrapper, type IWriter } from '~/lib/go/node/fs' +import { processStub } from '~/lib/go/node/process' +import { type GoWebAssemblyInstance, GoWrapper, wrapGlobal } from '~/lib/go' +import type { ExecParams, GoExecutor, Stdio, WriteListener } from './types' + +const intoWriter = (writeFn: WriteListener): IWriter => ({ + write: (data) => { + writeFn(data) + return data.byteLength + }, +}) + +class WorkerHandler implements GoExecutor { + private stdio: Stdio | undefined + + initialize(stdio: Stdio) { + this.stdio = stdio + } + + async run({ image, params }: ExecParams) { + if (!this.stdio) { + throw new Error('standard i/o streams are not configured') + } + + const fs = new FileSystemWrapper(intoWriter(this.stdio.stdout), intoWriter(this.stdio.stderr)) + const mocks = { + mocked: true, + process: processStub, + fs, + } + + const go = new GoWrapper(new globalThis.Go(), { + globalValue: wrapGlobal(mocks, globalThis), + }) + + const { instance } = await WebAssembly.instantiate(image, go.importObject) + return await new Promise((resolve, reject) => { + go.onExit = (code) => { + console.log('Go: WebAssembly program finished with code:', code) + resolve(code) + } + go.run(instance as GoWebAssemblyInstance, params?.args).catch(reject) + }) + } +} + +Comlink.expose(new WorkerHandler()) diff --git a/web/yarn.lock b/web/yarn.lock index 268fe129..5968bb97 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -3323,6 +3323,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +comlink@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.1.tgz#e568b8e86410b809e8600eb2cf40c189371ef981" + integrity sha512-+1dlx0aY5Jo1vHy/tSsIGpSkN4tS9rZSW8FIhG0JH/crs9wwweswIo/POr451r7bZww3hFbPAKnTpimzL/mm4Q== + commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"