dcp-client 5.4.1 → 5.4.3

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,197 @@
1
+ /**
2
+ * @file pyodide-worktime.js
3
+ * This file sets up the hooks for the Pyodide worktime. It registers the processSlice
4
+ * initSandbox hooks, which run slices and setup the sandbox respectively.
5
+ *
6
+ * @author Wes Garland <wes@distributive.network>
7
+ * @author Severn Lortie <severn@distributive.network>
8
+ * @author Will Pringle <will@distributive.network>
9
+ * @author Hamada Gasmallah <hamada@distributive.network>
10
+ *
11
+ * @date February, 2024
12
+ * @copyright Copyright (c) 2018-2024, Distributive, Ltd. All Rights Reserved.
13
+ */
14
+ 'use-strict';
15
+ /* global self, bravojs, addEventListener, postMessage */
16
+ // @ts-nocheck
17
+
18
+ self.wrapScriptLoading({ scriptName: 'pyodide-worktime'}, function pyodideWorktime$$fn(protectedStorage, ring1PostMessage, wrapPostMessage)
19
+ {
20
+ let wrappedPythonSliceHandler;
21
+ /**
22
+ * Registers the worktime callbacks that allow the worktime controller (worktimes.js) to setup our environment and run the worktime
23
+ */
24
+ function registerWorktime()
25
+ {
26
+ protectedStorage.worktimes.registerWorktime({
27
+ name: 'pyodide',
28
+ version: '0.28.0',
29
+ processSlice,
30
+ initSandbox,
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Function which generates a "map-basic"-like workFunction
36
+ * out of a Pyodide Worktime job (Python code, files, env variables).
37
+ *
38
+ * It takes any "images" passed in the workFunction "arguments" and
39
+ * writes them to the in memory filesystem provided by Emscripten.
40
+ * It adds any environment variables specified in the workFunction
41
+ * "arguments" to the pseudo-"process" for use.
42
+ * It globally imports a dcp module with function "set_slice_handler"
43
+ * which takes a python function as input. The python function passed
44
+ * to that slice handler is invoked by the function which this
45
+ * function creates.
46
+ *
47
+ * @param {Object} job The job being assigned to the sandbox
48
+ */
49
+ async function initSandbox(job)
50
+ {
51
+ var pythonSliceHandler;
52
+
53
+ const pyodide = await pyodideInit();
54
+ const sys = pyodide.pyimport('sys');
55
+
56
+ const findImports = pyodide.runPython('import pyodide; pyodide.code.find_imports');
57
+ const findPythonModuleLoader = pyodide.runPython('import importlib; importlib.util.find_spec');
58
+
59
+ const parsedArguments = parsePyodideArguments(job.arguments);
60
+
61
+ // write images to file and set environment variables
62
+ const prepPyodide = pyodide.runPython(`
63
+ import tarfile, io
64
+ import os, sys
65
+
66
+ def prepPyodide(args):
67
+ for image in args['images']:
68
+ image = bytes(image)
69
+ imageFile = io.BytesIO(image)
70
+ tar = tarfile.open(mode='r', fileobj=imageFile)
71
+
72
+ # Don't overwrite directories which corrupts Pyodide's in memory filesystem
73
+ def safe_extract(tar, path="/"):
74
+ for member in tar.getmembers():
75
+ if member.isdir():
76
+ dir_path = os.path.join(path, member.name)
77
+ if not os.path.exists(dir_path):
78
+ os.makedirs(dir_path)
79
+ else:
80
+ tar.extract(member, path)
81
+ safe_extract(tar)
82
+
83
+ for item, value in args['environmentVariables'].items():
84
+ os.environ[item] = value
85
+
86
+ sys.argv.extend(args['sysArgv'])
87
+
88
+ return
89
+
90
+ prepPyodide`);
91
+
92
+ prepPyodide(pyodide.toPy(parsedArguments));
93
+
94
+ // register the dcp Python module
95
+ if (!sys.modules.get('dcp'))
96
+ {
97
+ const create_proxy = pyodide.runPython('import pyodide;pyodide.ffi.create_proxy');
98
+
99
+ pyodide.registerJsModule('dcp', {
100
+ set_slice_handler: function pyodide$$dcp$$setSliceHandler(func) {
101
+ pythonSliceHandler = create_proxy(func);
102
+ },
103
+ progress,
104
+ });
105
+ }
106
+ pyodide.runPython( 'import dcp' );
107
+
108
+ // attempt to import packages from the package manager (if they're not already loaded)
109
+ const workFunctionPythonImports = findImports(job.workFunction).toJs();
110
+ const packageManagerImports = workFunctionPythonImports.filter(x=>!findPythonModuleLoader(x));
111
+ if (packageManagerImports.length > 0)
112
+ {
113
+ await fetchAndInitPyodidePackages(packageManagerImports);
114
+ await pyodide.loadPackage(packageManagerImports);
115
+ }
116
+
117
+ wrappedPythonSliceHandler = workFunctionWrapper;
118
+
119
+ /**
120
+ * Evaluates the Python WorkFunction string and then executes the slice
121
+ * handler Python function. If no call to `dcp.set_slice_handler` is passed
122
+ * or a non function is passed to it.
123
+ * This function specifically only takes one parameter since Pyodide Slice
124
+ * Handlers only accept one parameter.
125
+ */
126
+ async function workFunctionWrapper(datum)
127
+ {
128
+ const pyodide = await pyodideInit(); // returns the same promise when called multiple times
129
+
130
+ // load and execute the Python Workfunction, this populates the pythonSliceHandler variable
131
+ await pyodide.runPython(job.workFunction);
132
+
133
+ // failure to specify a slice handler is considered an error
134
+ if (!pythonSliceHandler)
135
+ throw new Error('ENOSLICEHANDLER: Must specify the slice handler using `dcp.set_slice_handler(fn)`');
136
+
137
+ // setting the slice handler to a non function or lambda is not supported
138
+ else if (typeof pythonSliceHandler !== 'function')
139
+ throw new Error('ENOSLICEHANDLER: Slice Handler must be a function');
140
+
141
+ const sliceHandlerResult = await pythonSliceHandler(pyodide.toPy(datum));
142
+
143
+ // if it is a PyProxy, convert its value to JavaScript
144
+ if (sliceHandlerResult?.toJs)
145
+ return sliceHandlerResult.toJs();
146
+
147
+ return sliceHandlerResult;
148
+ }
149
+
150
+ /*
151
+ * Refer to the "The Pyodide Worktime"."Work Function (JS)"."Arguments"."Commands"
152
+ * part of the DCP Worktimes spec.
153
+ */
154
+ function parsePyodideArguments(args)
155
+ {
156
+ var index = 1;
157
+ const numArgs = args[0];
158
+ const images = [];
159
+ const environmentVariables = {};
160
+ const sysArgv = args.slice(numArgs);
161
+
162
+ while (index < numArgs)
163
+ {
164
+ switch (args[index])
165
+ {
166
+ case 'gzImage':
167
+ const image = args[index+1];
168
+ images.push(image);
169
+ index+=2;
170
+ break;
171
+ case 'env':
172
+ const env = args[index+1].split(/=(.*)/s);
173
+ index+=2;
174
+ environmentVariables[env[0]] = env[1];
175
+ break;
176
+ default:
177
+ throw new Error(`Invalid argument ${args[index]}`);
178
+ }
179
+ }
180
+
181
+ return { sysArgv, images, environmentVariables };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The processSlice hook function which runs the work function for input slice data
187
+ * @param {*} data The slice data
188
+ * @returns {*} The slice result
189
+ */
190
+ async function processSlice(data)
191
+ {
192
+ const result = await wrappedPythonSliceHandler(data);
193
+ return result;
194
+ }
195
+
196
+ registerWorktime();
197
+ }); /* end of fn */
@@ -1,66 +1,294 @@
1
1
  /**
2
- * @file worktimes.js
3
- * Specify available worktimes, allow registering custom worktimes
4
- * The single source of authority for what Worktimes are available.
5
- *
6
- * @author Will Pringle <will@distributive.network>
7
- * Hamada Gasmallah <hamada@distributive.network>
8
- * @date January 2024
2
+ * @file dcp-client/libexec/sandbox/worktimes.js
3
+ * Specify available worktimes, allow registering custom worktimes
4
+ * The single source of authority for what Worktimes are available.
5
+ *
6
+ * @author Wes Garland, wes@distributive.network
7
+ * Severn Lortie, severn@distributive.network
8
+ * Will Pringle, will@distributive.network
9
+ * Hamada Gasmallah, hamada@distributive.network
10
+ * @date January 2024
11
+ * @copyright Copyright (c) 2018-2024, Distributive, Ltd. All Rights Reserved.
9
12
  */
10
- 'use strict';
13
+ 'use-strict';
14
+ /* global self, bravojs, addEventListener, postMessage */
15
+ // @ts-nocheck
11
16
 
12
- function worktimes$$fn(protectedStorage, _ring2PostMessage)
13
- {
17
+ self.wrapScriptLoading({ scriptName: 'worktimes' }, function worktimes$$fn(protectedStorage, ring1PostMessage, wrapPostMessage) {
18
+ // This file starts at ring 2, but transitions to ring 3 partway through it.
19
+ const ring2PostMessage = self.postMessage;
20
+ let ring3PostMessage;
21
+ protectedStorage.worktimes = { registeredWorktimes: [] };
14
22
  // when preparing a worktime, add it's globals to this object.
15
23
  // only if the job assigned to the evaluator uses that worktime, they will
16
24
  // be added to the allow-list
17
25
  protectedStorage.worktimeGlobals = {};
18
26
 
19
- const worktimes = [
20
- { name: 'map-basic', versions: ['1.0.0'] },
21
- { name: 'pyodide', versions: ['0.28.0'] },
22
- ];
27
+ /**
28
+ * Register a worktime with the evaluator.
29
+ * @param {object} worktimeInfo Information about the worktime
30
+ * @param {string} worktimeInfo.name The name of the worktime
31
+ * @param {string} worktimeInfo.version The semantic version (semver) number of the worktime
32
+ * @param {function} worktimeInfo.initSandbox A function which initializes the worktime. It is passed the job as its only argument
33
+ * @param {function} worktimeInfo.processSlice A function which runs a slice. Must take the form processSlice(data) --> result
34
+ */
35
+ function registerWorktime(worktimeInfo)
36
+ {
37
+ protectedStorage.worktimes.registeredWorktimes.push(worktimeInfo);
38
+ }
39
+ protectedStorage.worktimes.registerWorktime = registerWorktime;
23
40
 
24
- function registerWorktime(name, version)
41
+ /**
42
+ * Returns a list of worktimes in the form [{name: string, version:string}]
43
+ * @returns {object[]} array of worktimeInfo objects
44
+ */
45
+ function getWorktimeList()
25
46
  {
26
- const foundWorktime = globalThis.worktimes.find(wt => wt.name === name);
27
- // if we found a worktime and the version isn't already added, add it
28
- if (foundWorktime && !foundWorktime.versions.includes(version))
29
- foundWorktime.versions.push(version);
30
- // if this is a new worktime, add it
31
- else if (!foundWorktime)
32
- globalThis.worktimes.push({ name, versions: [version]});
47
+ const worktimeList = [];
48
+ for (const worktime of protectedStorage.worktimes.registeredWorktimes)
49
+ worktimeList.push({ name: worktime.name, version: worktime.version });
50
+ return worktimeList;
33
51
  }
52
+ protectedStorage.worktimes.getWorktimeList = getWorktimeList;
53
+ globalThis.getWorktimeList = getWorktimeList; // hook for worker-info.js, removed by access-list before sandboxes see it
34
54
 
35
- // nodejs-like environment
36
- if (typeof module?.exports === 'object')
37
- exports.worktimes = worktimes;
38
- else // inside the sandbox
55
+ /**
56
+ * Get a worktime given its name and version.
57
+ * @note The version parameter here is not a semver range. It must be a literal version, e.g. 0.23.1
58
+ * @param {string} name The name of the worktime
59
+ * @param {string} version The version of the worktime
60
+ * @returns {object} The worktime
61
+ */
62
+ function getWorktime(name, version)
39
63
  {
40
- globalThis.worktimes = worktimes;
41
- globalThis.registerWorktime = registerWorktime;
64
+ return protectedStorage.worktimes.registeredWorktimes.find(wt => wt.name === name && wt.version === version);
42
65
  }
43
- }
44
-
45
- // nodejs-like environment
46
- if (typeof module?.exports === 'object')
47
- {
48
- worktimes$$fn({});
49
- // sort the worktime versions from lowest to greatest
50
- const semver = require('semver');
51
- for (const wt of exports.worktimes)
66
+ protectedStorage.worktimes.getWorktime = getWorktime;
67
+
68
+ //Listens for postMessage from the sandbox
69
+ addEventListener('message', async function worktimes$$sandboxPostMessageHandler(event) {
70
+ let message = event;
71
+ switch (message.request)
72
+ {
73
+ /* Sandbox assigned a specific job by supervisor */
74
+ case 'assign':
75
+ {
76
+ try
77
+ {
78
+ if (typeof module.main !== 'undefined')
79
+ throw new Error('Main module was provided before job assignment');
80
+
81
+ protectedStorage.sandboxConfig = message.sandboxConfig;
82
+ Object.assign(self.work.job.public, message.job.public); /* override locale-specific defaults if specified */
83
+ mainModuleFactoryFactory(message.job);
84
+ }
85
+ catch (error)
86
+ {
87
+ ring2PostMessage({request: 'reject', error});
88
+ }
89
+ break; /* end of assign */
90
+ }
91
+ /* Supervisor has asked us to execute the work function. message.data is input datum. */
92
+ case 'main':
93
+ {
94
+ try
95
+ {
96
+ await runWorkFunction(message.data);
97
+ }
98
+ catch (error)
99
+ {
100
+ ring3PostMessage({ request: 'sandboxError', error });
101
+ }
102
+ break;
103
+ }
104
+ default:
105
+ break;
106
+ }
107
+ })
108
+
109
+ /* Report metrics to sandbox/supervisor */
110
+ function reportTimes (metrics)
52
111
  {
53
- wt.versions.sort((left, right) => {
54
- if (semver.lt(left, right))
55
- return 1;
56
- else if (semver.eq(left, right))
57
- return 0;
58
- else
59
- return -1;
60
- });
112
+ const { total, webGL, webGPU, CPU } = metrics;
113
+ ring3PostMessage({ request: 'measurement', total, webGL, webGPU, CPU });
61
114
  }
62
- }
63
- // inside the sandbox
64
- else
65
- self.wrapScriptLoading({ scriptName: 'worktimes' }, worktimes$$fn);
66
115
 
116
+ /* Report an error from the work function to the supervisor */
117
+ function reportError (error, metrics)
118
+ {
119
+ const err = new Error('initial state');
120
+
121
+ for (let prop of [ 'message', 'name', 'code', 'stack', 'lineNumber', 'columnNumber' ])
122
+ {
123
+ try
124
+ {
125
+ if (typeof error[prop] !== 'undefined')
126
+ err[prop] = error[prop];
127
+ }
128
+ catch(e){};
129
+ }
130
+
131
+ reportTimes(metrics); // Report metrics for both 'workReject' and 'workError'.
132
+ ring3PostMessage({ request: 'workError', error: err });
133
+ }
134
+
135
+ /**
136
+ * Report a result from work function and metrics to the supervisor.
137
+ * @param result the value that the work function returned promise resolved to
138
+ */
139
+ function reportResult (result, metrics)
140
+ {
141
+ try
142
+ {
143
+ reportTimes(metrics);
144
+ ring3PostMessage({ request: 'complete', result });
145
+ }
146
+ catch (error)
147
+ {
148
+ ring3PostMessage({ request: 'sandboxError', error });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Actual mechanics for running a work function. ** This function will never reject **
154
+ *
155
+ * @param successCallback callback to invoke when the work function has finished running;
156
+ * it receives as its argument the resolved promise returned from
157
+ * the work function
158
+ * @param errorCallback callback to invoke when the work function rejects. It receives
159
+ * as its argument the error that it rejected with.
160
+ * @returns unused promise
161
+ */
162
+ async function runWorkFunction_inner(datum, wallDuration, successCallback, errorCallback)
163
+ {
164
+ /** @typedef {import("./timer-classes.js").TimeInterval} TimeInterval */
165
+ var rejection = false;
166
+ var result;
167
+ let metrics;
168
+ protectedStorage.emitConsoleMessages = true;
169
+ protectedStorage.flushConsoleBuffer();
170
+ try
171
+ {
172
+ result = await module.main.worktime.processSlice(datum);
173
+ }
174
+ catch (error)
175
+ {
176
+ rejection = error;
177
+ }
178
+
179
+ try
180
+ {
181
+ // reset the device states and flush all pending tasks
182
+ protectedStorage.lockTimers(); // lock timers so no new timeouts will be run.
183
+ if (protectedStorage.webGPU)
184
+ protectedStorage.webGPU.lock();
185
+
186
+ // Let microtask queue finish before getting metrics. With all event-loop possibilities locked,
187
+ // only the microtask could trigger new code, so waiting for a setTimeout guarantees everything's done
188
+ await new Promise((r) => protectedStorage.realSetTimeout.call(globalThis, r, 1));
189
+
190
+ // flush any pending console events, especially in the case of a repeating message that hasn't been emitted yet
191
+ // then, disable emission of any more console messages
192
+ try { protectedStorage.dispatchSameConsoleMessage(); } catch(e) {};
193
+ protectedStorage.emitConsoleMessages = false;
194
+
195
+ metrics = await protectedStorage.bigBrother.globalTrackers.getMetrics();
196
+
197
+ await protectedStorage.bigBrother.globalTrackers.reset();
198
+ }
199
+ catch (error)
200
+ {
201
+ ring3PostMessage({ request: 'sandboxError', error });
202
+ }
203
+ finally
204
+ {
205
+ // due to the nature of the micro task queue, await, our `reset()` cancels all the things that could cause new
206
+ // tasks, and we wait for all pending task to finish in `reset()`, we are guaranteed to have an empty task queue
207
+ // now. Hence it's ok to stop the wall clock measurement now
208
+ wallDuration.stop();
209
+
210
+ // safety: wallDuration is always stopped, `length` will not throw
211
+ metrics = { ...metrics, total: wallDuration.length };
212
+ }
213
+
214
+ if (rejection)
215
+ errorCallback(rejection, metrics);
216
+ else
217
+ successCallback(result, metrics);
218
+
219
+ /* CPU time measurement ends when this function's return value is resolved or rejected */
220
+ }
221
+
222
+ /**
223
+ * Run the work function, returning a promise that resolves once the function has finished
224
+ * executing.
225
+ *
226
+ * @param datam an element of the input set
227
+ */
228
+ async function runWorkFunction(datum)
229
+ {
230
+ // reset the time used for feature detection
231
+ protectedStorage.bigBrother.globalTrackers.resetRecordedTime();
232
+ const wallDuration = new protectedStorage.TimeInterval();
233
+ protectedStorage.bigBrother.globalTrackers.wallDuration = wallDuration;
234
+
235
+ if (protectedStorage.webGPU)
236
+ protectedStorage.webGPU.unlock();
237
+ await protectedStorage.unlockTimers();
238
+
239
+ /* Use setTimeout trampoline to
240
+ * 1. shorten stack
241
+ * 2. initialize the event loop measurement code
242
+ */
243
+ protectedStorage.setTimeout(() => runWorkFunction_inner(datum, wallDuration, reportResult, reportError));
244
+ }
245
+ /**
246
+ * Factory function which returns the main module factory for use as the second argument in module.declare.
247
+ *
248
+ * @param {object} job the job property of the assign message from the supervisor.
249
+ * @returns {function} mainModuleFactory
250
+ */
251
+ function mainModuleFactoryFactory(job)
252
+ {
253
+ module.declare(job.dependencies, mainModuleFactory);
254
+
255
+ /* mainModule: this is the function that is run first by BravoJS once the module system has been
256
+ * loaded. It functions as the main module in a CommonJS environment, and its job is to initialize
257
+ * the work function for use. Once initialized, the work function is accessible as the main module's
258
+ * `job` export. The work function is eventually invoked by a message from the supervisor which
259
+ * invokes runWorkFunction() above.
260
+ *
261
+ * This function is infallible; any exceptions or rejections caught are reported to the Supervisor.
262
+ */
263
+ async function mainModuleFactory(require, exports, module)
264
+ {
265
+ try
266
+ {
267
+ if (exports.hasOwnProperty('job'))
268
+ throw new Error("Tried to assign sandbox when it was already assigned"); /* Should be impossible - might happen if throw during assign? */
269
+ exports.worktime = false;
270
+ job.requirePath.map(p => require.paths.push(p));
271
+ job.modulePath.map(p => module.paths.push(p));
272
+
273
+ exports.worktime = protectedStorage.worktimes.getWorktime(job.worktime.name, job.worktime.version);
274
+ if (!exports.worktime)
275
+ throw new Error(`Unsupported worktime: ${job.worktime.name}`);
276
+ await exports.worktime.initSandbox(job);
277
+ }
278
+ catch(error)
279
+ {
280
+ reportError(error);
281
+ return;
282
+ }
283
+
284
+ ring2PostMessage({
285
+ request: 'assigned',
286
+ jobAddress: job.address,
287
+ });
288
+
289
+ // Now that the evaluator is assigned, wrap post message for ring 3
290
+ wrapPostMessage();
291
+ ring3PostMessage = self.postMessage;
292
+ } /* end of main module */
293
+ }
294
+ }); /* end of worktimes$$wrapScriptLoading IIFE */
@@ -11,6 +11,28 @@ cd `dirname "$0"`/..
11
11
  yellow='\e[33m'
12
12
  normal='\e[0m'
13
13
 
14
+ currentWorktimes=$(CONSOLE_RESULTS=true node build/generate-worktimes-json)
15
+ recordedWorktimes=$(< ./generated/worktimes.json)
16
+ if [[ "$currentWorktimes" != "$recordedWorktimes" ]]; then
17
+ echo
18
+ echo -e "Generated worktimes: ${recordedWorktimes}" > /dev/stderr
19
+ echo -e "Current worktimes: ${currentWorktimes}" > /dev/stderr
20
+ echo -e "${yellow}pre-publish: abort due to generated worktimes not matching currently available worktimes${normal}" > /dev/stderr
21
+ echo
22
+ exit 1
23
+ fi
24
+
25
+ currentSandboxFiles=$(CONSOLE_RESULTS=true node build/generate-sandbox-definitions-json)
26
+ recordedSandboxFiles=$(< ./generated/sandbox-definitions.json)
27
+ if [[ "$currentSandboxFiles" != "$recordedSandboxFiles" ]]; then
28
+ echo
29
+ echo -e "Generated sandbox definitions: ${currentSandboxFiles}" > /dev/stderr
30
+ echo -e "Current sandbox definitions: ${recordedSandboxFiles}" > /dev/stderr
31
+ echo -e "${yellow}pre-publish: abort due to generated sandbox definitions not matching generation file${normal}" > /dev/stderr
32
+ echo
33
+ exit 1
34
+ fi
35
+
14
36
  git ls-files --error-unmatch `find . -type f | egrep -v '^\./(node_modules|\.git|\.dcp-build|build)'` >/dev/null
15
37
  if [ "$?" != "0" ]; then
16
38
  echo
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dcp-client",
3
- "version": "5.4.1",
3
+ "version": "5.4.3",
4
4
  "dcp": {
5
- "version": "d91005494a226e4361d9ae1a2f739dc2dc45750a",
5
+ "version": "3853a8dc41e0c33886fc337534f5c21aaf62dc56",
6
6
  "repository": "git@gitlab.com:Distributed-Compute-Protocol/dcp.git"
7
7
  },
8
8
  "description": "Core libraries for accessing DCP network",