dcp-worker 4.1.1 → 4.2.1

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.
@@ -50,11 +50,21 @@ class Log extends Box
50
50
  this.setContent(this.lastLogContent);
51
51
  if (!this.paused)
52
52
  this.setScrollPerc(100);
53
+ if (this.pendingUpdate)
54
+ {
55
+ this.pendingUpdate = true;
56
+ setImmediate(() => {
57
+ this.pendingUpdate = false;
58
+ this.screen.render();
59
+ });
60
+ }
61
+
53
62
  }
54
63
 
55
64
  advanceThrob(char)
56
65
  {
57
66
  this.setContent(this.lastLogContent + char);
67
+ this.screen.render();
58
68
  }
59
69
  }
60
70
 
@@ -17,6 +17,7 @@ exports.check = function checkSchedulerVersion$$check(quiet)
17
17
  {
18
18
  const dcpConfig = require('dcp/dcp-config');
19
19
  const schedulerConfig = dcpConfig.scheduler;
20
+ const { errorCodes } = require('./consts');
20
21
 
21
22
  // Check for old versions of the config
22
23
  if (!schedulerConfig.worker)
@@ -50,9 +51,9 @@ exports.check = function checkSchedulerVersion$$check(quiet)
50
51
  `This worker is: ${currentWorkerType}, ${currentWorkerVersion}\n` +
51
52
  `dcp-client: ${require('util').inspect(require('dcp/build'))}\n`);
52
53
  if (process.env.DCP_WORKER_CHECK_SEMVER !== 'false')
53
- process.exit(1);
54
+ process.exit(exitCodes.invalidScheduler);
54
55
  }
55
56
 
56
57
  if (!quiet)
57
- console.log(`The current scheduler supports worker type(s) ${Object.values(schedulerConfig.worker.types)} and operations ${schedulerConfig.worker.operations}`);
58
+ console.log(` . Scheduler supports worker type(s) ${Object.values(schedulerConfig.worker.types)} and operations ${schedulerConfig.worker.operations}`);
58
59
  }
package/lib/consts.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @file consts.js - Constants for use within dcp-worker program
3
+ * @author Wes Garland, wes@distributive.network
4
+ * @date June 2025
5
+ */
6
+ 'use strict';
7
+
8
+ require('dcp-client');
9
+
10
+ const { ConstantGroup } = require('dcp/utils');
11
+ exports.exitCodes = new ConstantGroup({
12
+ normal: 0, /* 0=no error, 1-15 used by Node.js */
13
+ error: 22, /* misc errors */
14
+ invalid: 24,
15
+ unhandled: 25, /* unhandled rejection or uncaught exception */
16
+ lostRef: 26, /* lost worker reference unexpectedly */
17
+ userError: 27, /* user made an error in cli opts */
18
+
19
+ invalidScheduler: 80, /* scheduler version check failed */
20
+ pidConflict: 81, /* pidfile library can't create unique pidfile */
21
+ workerError: 82, /* worker.on('error') emitted */
22
+ });
23
+
24
+ /* log levels, ordered from least to most important */
25
+ exports.logLevels = new ConstantGroup(['debug', 'log', 'info', 'warn', 'error']);
package/lib/loggers.js ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @file startWorkerLogger.js
3
+ * Start the DCP Worker logging subsystem. Sets console.log (etc) redirection, determine
4
+ * the correct log target (screen, TUI, syslog, windows event log, file) and redirect the
5
+ * output there.
6
+ *
7
+ * This module's exports are:
8
+ * - hook() - replace console with new console and multiplex its logs to loggers
9
+ * - unhook() - go back to normal
10
+ * - exists() - determine if a given logger is exists or not
11
+ *
12
+ * A logger module's exports are its API. The following functions are supported, in this order
13
+ * of severity:
14
+ * . debug: debug-level message
15
+ * . info: informational message
16
+ * . log: normal, but significant, condition
17
+ * . warn: warning conditions
18
+ * . error: error conditions
19
+ *
20
+ * Additionally, generic functions may be used when one of the above is not defined:
21
+ * . at: write a log message at a specific log level; the level is the first argument
22
+ * . raw: same as at, but arguments are not formatted
23
+ * . any: write a log message without regard to log level
24
+ *
25
+ * All of these functions, with the exception of raw, receive only string arguments.
26
+ *
27
+ * When this library is initialized, it initializes all of the loggers that the program is using (per
28
+ * options.loggers). Just before initialization, each logger module has a handleFatalError export added
29
+ * to it. Loggers should use this export to report errors which are fatal to the logger back to this
30
+ * module, so that the logger can be removing from the multiplex list.
31
+ *
32
+ * This is completely untested, however, it should be possible to specify an arbitrary logger which is
33
+ * outside of the dcp-worker package by specifying the absolute path to a module implementing the logger
34
+ * API as the logger name.
35
+ *
36
+ * @author Ryan Rossiter, ryan@kingsds.network
37
+ * @date April 2020
38
+ * @author Wes Garland, wes@distributive.network
39
+ * @date Jun 2025
40
+ */
41
+ 'use strict';
42
+
43
+ const process = require('process');
44
+ const path = require('path');
45
+ const util = require('util');
46
+ const debug = require('debug');
47
+
48
+ const { logLevels } = require('./consts');
49
+ const logLevelValues = Object.values(logLevels);
50
+ const loggers = [];
51
+ const { Reference } = require('dcp/dcp-timers');
52
+ var preHookConsole;
53
+
54
+ /**
55
+ * Default options for util.inspect. Merged with each logger library for individual inspect preferences.
56
+ */
57
+ const inspectOptions = {
58
+ colors: process.env.FORCE_COLOR ? true : 'auto',
59
+ };
60
+
61
+ /**
62
+ * @param {string} loggerName name of the logger (eg syslog, logfile)
63
+ * @returns {object} logger's module exports
64
+ */
65
+ function requireLogger(loggerName)
66
+ {
67
+ try
68
+ {
69
+ debug('dcp-worker:logger')(`loading logger '${loggerName}'`);
70
+ return require(path.resolve(__dirname, 'worker-loggers', loggerName));
71
+ }
72
+ catch (error)
73
+ {
74
+ if (error.code === 'MODULE_NOT_FOUND')
75
+ {
76
+ const errorMessageStart = error.message.replace(/\n.*/g, '');
77
+ error.message = `Unknown logger '${loggerName}' (${errorMessageStart})`;
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Check to see if a given string is the name of a logger (eg syslog)
85
+ * @param {string} loggerName name of logger
86
+ * @returns true iff loggerName is the name of a logger
87
+ */
88
+ exports.exists = function loggers$exists(loggerName)
89
+ {
90
+ try
91
+ {
92
+ return Boolean(requireLogger(loggerName));
93
+ }
94
+ catch(error)
95
+ {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Replace globalThis.console with the new console object, after modifying it so that it logs to the
102
+ * loggers in addition to the console.
103
+ *
104
+ * Each logger (module in ./worker-loggers) represents a different type of non-console logging. This
105
+ * currently (jun 2025) includes syslog, a log file, and the windows event log. It is arbitrarily
106
+ * expandable to including additional loggger types in the future, however the architecture currently
107
+ * limits us to one log target (destination) per logger type.
108
+ *
109
+ * @param {object} options - loggingOptions from dcp-worker
110
+ */
111
+ exports.hook = function loggers$$hook(console, options)
112
+ {
113
+ debug('dcp-worker:log')('Initializing loggers:', options.loggers);
114
+
115
+ for (let loggerName of options.loggers)
116
+ {
117
+ const logger = requireLogger(loggerName);
118
+ loggers.push(logger);
119
+ logger.inspectOptions = Object.assign({}, inspectOptions, logger.inspectOptions);
120
+ logger.handleFatalError = function loggers$$handleFatalError(error) {
121
+ const idx = loggers.indexOf(logger);
122
+ if (idx === -1)
123
+ return;
124
+ debug('dcp-worker:logger')(`${loggerName} logger reports fatal error`, error);
125
+ loggers.splice(idx, 1);
126
+ const msg = `Disabling logger ${loggerName}: ${error?.message}` + (error?.code ? ` (${error.code})` : '');
127
+ console.error(msg);
128
+ multiplex(logLevels.error, msg);
129
+ };
130
+ try
131
+ {
132
+ logger.open(options);
133
+ }
134
+ catch(error)
135
+ {
136
+ logger.handleFatalError(error);
137
+ }
138
+ }
139
+
140
+ function multiplex(level, ...args)
141
+ {
142
+ for (let loggerName in loggers)
143
+ {
144
+ if (isLessImportant(level, options.minLevel[loggerName] || options.minLevel.all))
145
+ continue;
146
+ const logger = loggers[loggerName];
147
+ if (logger[level])
148
+ logger[level](...format(logger, ...args));
149
+ else if (logger.at)
150
+ logger.at(level, ...format(logger, ...args));
151
+ else if (logger.raw)
152
+ logger.raw(level, ...args);
153
+ else if (logger.any)
154
+ logger.any(`${level}:`, format(logger, ...args));
155
+ else
156
+ throw new Error(`logger ${loggerName} missing implementation for '${level}'`);
157
+ }
158
+ }
159
+
160
+ /* Replace the global console object with a console object which is derived from the console argument
161
+ * to this function, but modified so that it will multiplex its output to all loggers.
162
+ */
163
+ preHookConsole = globalThis.console;
164
+ globalThis.console = Object.create(console.constructor.prototype);
165
+ globalThis.console.constructor = console.constructor;
166
+ Object.assign(globalThis.console, console);
167
+
168
+ for (let level of Object.values(logLevels))
169
+ {
170
+ globalThis.console[level] = function loggers$$consoleHookWrapper(...args) {
171
+ multiplex(level, ...args);
172
+ if (!isLessImportant(level, options.minLevel.console || options.minLevel.all))
173
+ console[level](...args);
174
+ };
175
+ }
176
+
177
+ require('./telnetd').reintercept();
178
+
179
+ /**
180
+ * Console.throb is effectively console.info from the POV of remote loggers, but on the local console,
181
+ * it is slightly different because it can place the cursor and advances a throbber.
182
+ * (API - no args = advance throbber, else print message)
183
+ */
184
+ globalThis.console.throb = (...args) => {
185
+ console.throb(...args);
186
+ multiplex('info', ...args);
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Format console.log arguments for use by a non-native logger, eg syslog. All non-string arguments are
192
+ * converted into the best human-readable strings we can muster.
193
+ */
194
+ function format(logger, ...argv)
195
+ {
196
+ for (let i in argv)
197
+ {
198
+ try
199
+ {
200
+ if (typeof argv[i] === 'object' && argv[i] instanceof String)
201
+ argv[i] = String(argv[i]);
202
+ argv[i] = typeof argv[i] === 'string' ? argv[i] : util.inspect(argv[i], logger.inspectOptions);
203
+ }
204
+ catch(e)
205
+ {
206
+ argv[i] = '[encoding error: ' + e.message + ']';
207
+ }
208
+ }
209
+
210
+ return argv;
211
+ }
212
+
213
+ /**
214
+ * Returns true if log level1 is less important than log level2
215
+ */
216
+ function isLessImportant(level1, level2)
217
+ {
218
+ if (typeof level2 === 'undefined')
219
+ return false;
220
+ if (level1 === level2)
221
+ return false;
222
+
223
+ const idx1 = logLevelValues.indexOf(level1);
224
+ const idx2 = logLevelValues.indexOf(level2);
225
+
226
+ return idx1 < idx2;
227
+ }
228
+
229
+ /**
230
+ * Close all loggers that have close functions that were initialized during hook(), removing them
231
+ * from the multiplex list just before they are closed. An event loop reference is added for the
232
+ * duration of this function, so that we don't accidentally exit while waiting for loggers to flush.
233
+ */
234
+ exports.unhook = async function loggers$$unhook()
235
+ {
236
+ debug('dcp-worker:exit')('unhooking', loggers.length, 'loggers');
237
+ const elref = new Reference().ref();
238
+ for (let logger; (logger = loggers.pop());)
239
+ {
240
+ try
241
+ {
242
+ await logger.close();
243
+ }
244
+ catch(error)
245
+ {
246
+ debug('dcp-worker:exit')('error closing logger;', error);
247
+ }
248
+ }
249
+ elref.unref();
250
+
251
+ if (preHookConsole)
252
+ {
253
+ globalThis.console = preHookConsole;
254
+ require('./telnetd').reintercept();
255
+ }
256
+ }
@@ -1,7 +1,9 @@
1
1
  /**
2
- * @file node-version-check.js - preload module
2
+ * @file node-version-check.js - preload module, not part of dcp-worker
3
3
  * @author Wes Garland, wes@distributive.network
4
4
  * @date Sep 2024
5
+ *
6
+ * @example: node -r ./lib/node-version-check bin/dcp-worker
5
7
  */
6
8
  'use strict';
7
9
 
@@ -9,5 +11,5 @@ if (!(parseInt(process.versions.node, 10) >= 18))
9
11
  {
10
12
  console.error(`Your version of node (${process.version}) is far to old to run this program. Please upgrade to a supported version of Node.js.`);
11
13
  console.error('For more information, visit', require('../package').homepage);
12
- process.exit(1);
14
+ process.exit(127);
13
15
  }
package/lib/pidfile.js CHANGED
@@ -9,6 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const fs = require('fs');
12
+ const os = require('os');
12
13
  const path = require('path');
13
14
 
14
15
  /**
@@ -20,11 +21,13 @@ const path = require('path');
20
21
  * cleanup depends on the process<dcpExit> event fired by dcp-client during process tear-down.
21
22
  *
22
23
  * @param {string} filename the location of the pid file
24
+ * @returns true iff writing the pid file was successful
23
25
  */
24
26
  exports.write = function pidfile$$write(filename)
25
27
  {
26
28
  var fd;
27
-
29
+
30
+ console.debug(' . pidfile is', filename);
28
31
  if (fs.existsSync(filename))
29
32
  {
30
33
  console.warn(`Warning: found pidfile at ${filename}`);
@@ -48,20 +51,23 @@ exports.write = function pidfile$$write(filename)
48
51
  {
49
52
  console.error(`Process at PID ${oldPid} is still running; cannot start new process with pidfile ${filename}`);
50
53
  require('dcp/utils').sleep(3); /* put the brakes on a respawn loop */
51
- process.exit(1);
54
+ return false;
52
55
  }
53
56
  }
54
57
 
55
58
  try
56
59
  {
60
+ fs.mkdirSync(path.dirname(filename), { recursive: true });
57
61
  fd = fs.openSync(filename, 'wx');
58
62
  fs.writeSync(fd, Buffer.from(process.pid + '\n'), 0);
59
63
  process.on('dcpExit', removePidFile); // Cleanup PID file on exit
64
+ return true;
60
65
  }
61
66
  catch (error)
62
67
  {
63
68
  console.warn(`Warning: Could not create pidfile at ${filename} (${error.code || error.message})`);
64
69
  removePidFile();
70
+ return false;
65
71
  }
66
72
 
67
73
  function removePidFile()
@@ -74,7 +80,8 @@ exports.write = function pidfile$$write(filename)
74
80
  }
75
81
  catch (error)
76
82
  {
77
- console.warn(`Warning: Could not remove pidfile at ${filename} (${error.code})`);
83
+ if (fd)
84
+ console.warn(`Warning: Could not remove pidfile at ${filename} (${error.code})`);
78
85
  }
79
86
  }
80
87
  }
@@ -90,7 +97,84 @@ exports.getDefaultPidFileName = function getDefaultPidFileName(basename)
90
97
  var defaultPidFileName = basename || path.basename(require.main.filename, '.js') + '.pid';
91
98
 
92
99
  if (!path.isAbsolute(defaultPidFileName))
93
- defaultPidFileName = path.resolve(__dirname, '..', 'run', defaultPidFileName);
100
+ {
101
+ switch(os.platform())
102
+ {
103
+ case 'linux':
104
+ defaultPidFileName = path.resolve(process.env.XDG_RUNTIME_DIR || '/run/', defaultPidFileName);
105
+ break;
106
+ default:
107
+ defaultPidFileName = path.resolve(process.env.XDG_RUNTIME_DIR || '/var/run/', defaultPidFileName);
108
+ break;
109
+ case 'win32':
110
+ defaultPidFileName = path.resolve(process.env.LOCALAPPDATA || process.env.TEMP, 'Distributive/run');
111
+ break;
112
+ }
113
+ }
94
114
 
95
115
  return defaultPidFileName;
96
116
  }
117
+
118
+ /**
119
+ * Returns true if the signal was delivered or false if it was not delivered because the process was not
120
+ * running. Throws if there was an error (eg perms) delivering the signal.
121
+ */
122
+ function kill(pid, signal)
123
+ {
124
+ try
125
+ {
126
+ process.kill(pid, signal);
127
+ }
128
+ catch(error)
129
+ {
130
+ if (error.code === 'ESRCH') /* no such process */
131
+ return false;
132
+ throw error;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * Signal another process on this host. Handles the case of a dangnling pidfile by ignoring it.
139
+ * @param {string} filename the path to the pidfile, same semantics as exports.write()
140
+ * @param {string | number } signal the signal to send
141
+ * @param {number | undefined } timeout the number of seconds to wait for the process to die before
142
+ * rejecting with code ETIMEOUT
143
+ * @param {string | undefined} doingWarning console.throb() string to send just before signalling. If
144
+ * the string contains %i, it will be replaced with the pid.
145
+ * @rejects {Error} if we can't deliver the signal for an unexpected reason (eg. permssions) or the
146
+ * process took more than timeout seconds to die.
147
+ * @returns {Promise} which resolves true if the signal was delivered and the process died
148
+ */
149
+ exports.signal = async function pidfile$$signal(filename, signal, timeout, doingWarning)
150
+ {
151
+ const { a$sleep } = require('dcp/utils');
152
+ if (!filename || !fs.existsSync(filename))
153
+ return false;
154
+
155
+ const pid = parseInt(fs.readFileSync(filename, 'utf8'), 10);
156
+ if (!pid || kill(pid, 0) === false)
157
+ return false;
158
+
159
+ if (doingWarning)
160
+ console.throb(doingWarning.replace(/%i/g, pid));
161
+
162
+ kill(pid, signal);
163
+
164
+ const loopUntil = Date.now() + (timeout * 1e3);
165
+ while (kill(pid, 0) === true)
166
+ {
167
+ await a$sleep(0.10);
168
+ if (doingWarning)
169
+ console.throb();
170
+ if (Date.now() >= loopUntil)
171
+ {
172
+ const error = new Error(`timeout waiting for process ${pid} to exit`);
173
+ error.code = 'ETIMEOUT';
174
+ return error;
175
+ }
176
+ await a$sleep(0.40);
177
+ }
178
+
179
+ return true;
180
+ }
package/lib/reports.js ADDED
@@ -0,0 +1,95 @@
1
+ #! /usr/bin/env node
2
+ /**
3
+ * @file reports.js
4
+ * Interval reports for dcp-worker, based on code removed from dcp-worker
5
+ *
6
+ * @author Wes Garland, wes@distributive.network
7
+ * @date May 2025
8
+ */
9
+ 'use strict';
10
+
11
+ exports.generateSliceReport = generateSliceReport;
12
+ exports.printSliceReport = printSliceReport;
13
+
14
+ const hr = ('='.repeat(78)) + '\n';
15
+
16
+ function printSliceReport(worker)
17
+ {
18
+ const report = generateSliceReport(worker);
19
+ console.log(hr + report + hr);
20
+ return report;
21
+ }
22
+
23
+ /** generator a slice report screen */
24
+ function generateSliceReport(worker)
25
+ {
26
+ let report = '';
27
+
28
+ if (!worker)
29
+ return ' * worker not defined * ';
30
+
31
+ const sbStates = {
32
+ WORKING: 0,
33
+ ASSIGNED: 0,
34
+ READY: 0,
35
+ TERMINATED: 0,
36
+ };
37
+ const stateNames = {
38
+ WORKING: 'Working',
39
+ ASSIGNED: 'Assigned',
40
+ READY: 'Ready',
41
+ TERMINATED: 'Terminated',
42
+ };
43
+ worker.sandboxes?.forEach(sb => {
44
+ const { state } = sb;
45
+ if (!sbStates[state])
46
+ sbStates[state] = 0;
47
+ sbStates[state]++;
48
+ });
49
+
50
+ report += (Date()) + '\n';
51
+ report += ('Sandboxes:') + '\n';
52
+ Object.keys(sbStates).forEach(state => {
53
+ const stateName = stateNames[state] || state;
54
+ report += (` ${(stateName + ':').padEnd(12)} ${sbStates[state]}`) + '\n';
55
+ })
56
+ report += (` * ALL: ${worker.sandboxes?.length}`) + '\n';
57
+
58
+ report += ('Progress:') + '\n';
59
+ worker.workingSandboxes?.forEach(sb => {
60
+ const jobName = sb.job?.public?.name || `idek (${sb.jobId})`;
61
+ let el = Date.now() - sb.sliceStartTime;
62
+ const t = el < 1000000
63
+ ? toInterval(el)
64
+ : 'new';
65
+
66
+ el = sb.progressReports && sb.progressReports.last
67
+ ? Date.now() - (sb.sliceStartTime + (sb.progressReports.last?.timestamp ?? 0))
68
+ : 0;
69
+ const pct = (typeof sb.progress) === 'number'
70
+ ? `${Number(sb.progress).toFixed(0).padStart(2)}%`
71
+ : 'ind';
72
+ const stale = (el < 2000) ? '' : `(stale: ${toInterval(el)})`;
73
+
74
+ report += (` ${String(sb.id).padStart(4)}: ${sb.jobId} ${jobName.padEnd(34)} `+ `${t} ${pct} ${stale}`.padStart(13)) + '\n';
75
+ });
76
+
77
+ report += ('Slices:') + '\n';
78
+ report += (` working: ${worker.workingSlices?.length}`) + '\n';
79
+ report += (` queued: ${worker.queuedSlices?.length}`) + '\n';
80
+
81
+ return report
82
+ }
83
+
84
+ /**
85
+ * Convert a timespan in ms to a human-readable interval in minutes and seconds
86
+ *
87
+ * @param {number} el Milliseconds to convert
88
+ * @return {string} Timespan formatted as `m:ss`
89
+ */
90
+ function toInterval(el)
91
+ {
92
+ const m = Math.floor((el / 1000) / 60).toString(10);
93
+ const s = Math.floor((el / 1000) % 60).toString(10).padStart(2, '0');
94
+ return `${m}:${s}`;
95
+ }
package/lib/show.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @file show.js
3
+ * Implementation for --show-* command-line options
4
+ * @author Wes Garland
5
+ * @date July 2025
6
+ */
7
+ 'use strict';
8
+
9
+ exports.show = show;
10
+
11
+ async function show(worker, mode)
12
+ {
13
+ const identity = require('dcp/identity');
14
+ const { DistributiveWorker } = require('dcp/worker');
15
+
16
+ switch (mode)
17
+ {
18
+ default:
19
+ {
20
+ var value, path;
21
+
22
+ if (!mode.match(/^[a-zA-Z0-9_$.-]*$/))
23
+ throw new Error('invalid show mode ' + mode);
24
+ for (value = worker.config, path = mode.split('.');
25
+ path.length;
26
+ value = value[path.shift()])
27
+ ; /* walk the dot path to the end */
28
+ return value;
29
+ }
30
+ case 'config':
31
+ return worker.config;
32
+ case 'compute-groups':
33
+ return worker.inspect.computeGroups;
34
+ case 'worker-id':
35
+ return worker.id;
36
+ case 'allowed-origins':
37
+ return worker.originManager.origins;
38
+ case 'identity':
39
+ return identity.get().address;
40
+ case 'owner':
41
+ {
42
+ if (worker.config.unmanaged)
43
+ return undefined;
44
+ const db = await DistributiveWorker.jnuConnect();
45
+ const cursor = await db.select({
46
+ table: 'w$worker', prototype: {
47
+ id: worker.id,
48
+ }
49
+ });
50
+ const row = await cursor.getFirst();
51
+ return row?.owner;
52
+ }
53
+ }
54
+ }