@nosana/node 1.1.47-rc → 1.1.48-rc

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/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.47-rc",
3
+ "version": "1.1.48-rc",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "nosana-node": "./dist/src/index.js"
@@ -7,12 +7,11 @@ import { logStreaming } from './monitoring/streaming/LogStreamer.js';
7
7
  import { consoleLogging } from './monitoring/log/console/ConsoleLogger.js';
8
8
  import { validateCLIVersion } from '../version/index.js';
9
9
  import { checkForMaintenance } from './checkForMaintenance.js';
10
- import { getSDK } from './sdk/index.js';
11
10
  import { ping } from './monitoring/ping/PingHandler.js';
12
11
  import { LogMonitoringRegistry } from './monitoring/LogMonitoringRegistry.js';
13
12
  import { checkWSLStatus } from './utils/wslCheck.js';
14
- import { HostManager } from './node/market/hostManager.js';
15
13
  import { IN_JOB_LOOP_ENV } from '../exitCodes.js';
14
+ import { reportError } from './monitoring/reportError.js';
16
15
  export default class NodeManager {
17
16
  constructor(options) {
18
17
  this.exiting = false;
@@ -283,10 +282,3 @@ export default class NodeManager {
283
282
  });
284
283
  }
285
284
  }
286
- const reportError = async (data) => {
287
- const nosana = getSDK();
288
- return HostManager.reportError({
289
- address: nosana.solana.provider.wallet.publicKey.toString(),
290
- ...data,
291
- });
292
- };
@@ -0,0 +1,13 @@
1
+ export type NodeErrorReport = {
2
+ error_type: string;
3
+ error_name: string;
4
+ error_message: string;
5
+ error_stack: string;
6
+ };
7
+ /**
8
+ * Send an error to the host, so it is visible off the node.
9
+ *
10
+ * Never throws or rejects: callers are already handling a failure, some from
11
+ * signal handlers where a rejection would be the only trace left.
12
+ */
13
+ export declare function reportError(report: NodeErrorReport): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { getSDK } from '../sdk/index.js';
2
+ import { HostManager } from '../node/market/hostManager.js';
3
+ /**
4
+ * Send an error to the host, so it is visible off the node.
5
+ *
6
+ * Never throws or rejects: callers are already handling a failure, some from
7
+ * signal handlers where a rejection would be the only trace left.
8
+ */
9
+ export async function reportError(report) {
10
+ try {
11
+ const nosana = getSDK();
12
+ await HostManager.reportError({
13
+ address: nosana.solana.provider.wallet.publicKey.toString(),
14
+ ...report,
15
+ });
16
+ }
17
+ catch {
18
+ // Best effort: an unreachable host must not mask the original failure.
19
+ }
20
+ }
@@ -11,15 +11,28 @@ export declare class ApiHandler {
11
11
  private server;
12
12
  private wss;
13
13
  private eventEmitter;
14
- private apiCheckInterval;
14
+ private apiCheckTimer;
15
+ private failedProxyRestarts;
15
16
  constructor(sdk: SDK, repository: NodeRepository, provider: Provider, port: number);
16
17
  start(): Promise<string>;
17
18
  preventMultipleApiStarts(): Promise<void>;
18
- testApiServerOnce(server: string): Promise<boolean>;
19
+ testApiServerOnce(server: string, timeoutMs?: number): Promise<boolean>;
19
20
  private restartApiProxy;
20
21
  private stopApiProxy;
21
22
  private startWebSocketServer;
22
23
  private startApiCheck;
24
+ /**
25
+ * Check the API, scheduling the next check once this one is done: a rebuild
26
+ * takes as long as an image pull, and two cannot both create the proxy.
27
+ */
28
+ private scheduleApiCheck;
29
+ /**
30
+ * End the process once the proxy counts as lost, so a fresh one can build it:
31
+ * nothing else in this process rebuilds it. A job in flight outranks that,
32
+ * since resuming a flow runs its operations again, and an unreachable registry
33
+ * is not something a new process fixes.
34
+ */
35
+ private endNodeIfProxyIsLost;
23
36
  private registerRoutes;
24
37
  private listen;
25
38
  private stopApiCheck;
@@ -9,6 +9,16 @@ import { applyLoggingProxyToClass } from '../../monitoring/proxy/loggingProxy.js
9
9
  import { verifyJobOwnerSignatureMiddleware, verifyWSJobOwnerSignatureMiddleware, verifyWSNodeOrJobOwnerSignatureMiddleware, } from './middlewares/index.js';
10
10
  import { getNodeInfoRoute, getJobResultsRoute, getServiceUrlRoute, postJobDefinitionRoute, postServiceStopRoute, wssLogRoute, wssStatusRoute, getCurrentGroupStatusHandler, getGroupStatusHandler, getOperationsStatusHandler, getOperationStatusHandler, restartGroupOperationHandler, restartOperationHandler, stopGroupOperationHandler, stopOperationHandler, wssTaskManagerLogRoute, getJobDefinitionRoute, getJobInfoRoute, getJobStatsRoute, getJobStatsStreamRoute, } from './routes/index.js';
11
11
  import { NodeAlreadyActiveError } from '../../errors/NodeAlreadyActiveError.js';
12
+ import { reportError } from '../../monitoring/reportError.js';
13
+ import { EXIT_CODES } from '../../../exitCodes.js';
14
+ import { TaskManagerRegistry } from '../task/TaskManagerRegistry.js';
15
+ /** Rebuilds to fail before the process is replaced. */
16
+ const MAX_PROXY_RESTART_FAILURES = 3;
17
+ const API_CHECK_TIMEOUT_MS = 10 * 1000;
18
+ /** Longer than the check: a slow answer means a node is there, not absent. */
19
+ const API_TEST_TIMEOUT_MS = 60 * 1000;
20
+ const API_CHECK_INTERVAL_MS = 5 * 60 * 1000;
21
+ const API_CHECK_MIN_GAP_MS = 30 * 1000;
12
22
  export class ApiHandler {
13
23
  constructor(sdk, repository, provider, port) {
14
24
  this.sdk = sdk;
@@ -18,7 +28,8 @@ export class ApiHandler {
18
28
  this.server = null;
19
29
  this.wss = null; // WebSocket server
20
30
  this.eventEmitter = ApiEventEmitter.getInstance();
21
- this.apiCheckInterval = null;
31
+ this.apiCheckTimer = null;
32
+ this.failedProxyRestarts = 0;
22
33
  this.address = this.sdk.solana.provider.wallet.publicKey;
23
34
  this.api = express();
24
35
  this.api.use(cors());
@@ -41,9 +52,13 @@ export class ApiHandler {
41
52
  throw new NodeAlreadyActiveError(this.address.toString());
42
53
  }
43
54
  }
44
- async testApiServerOnce(server) {
55
+ async testApiServerOnce(server, timeoutMs = API_TEST_TIMEOUT_MS) {
45
56
  try {
46
- const response = await fetch(`${server}`);
57
+ // A tunnel that is down accepts the connection and answers nothing, so
58
+ // without a deadline this waits minutes for the runtime's own.
59
+ const response = await fetch(`${server}`, {
60
+ signal: AbortSignal.timeout(timeoutMs),
61
+ });
47
62
  if (!response.ok)
48
63
  return false;
49
64
  const responseText = await response.json();
@@ -66,7 +81,6 @@ export class ApiHandler {
66
81
  }
67
82
  async stopApiProxy() {
68
83
  await this.provider.stopReverseProxyApi(this.address.toString());
69
- this.stopApiCheck();
70
84
  }
71
85
  async startWebSocketServer() {
72
86
  this.wss = new WebSocket.Server({ noServer: true });
@@ -111,15 +125,72 @@ export class ApiHandler {
111
125
  });
112
126
  }
113
127
  startApiCheck(server) {
114
- if (!this.apiCheckInterval) {
115
- this.apiCheckInterval = setInterval(async () => {
116
- const isAlive = await this.testApiServerOnce(server);
117
- if (!isAlive) {
118
- console.log('API proxy is offline, restarting..');
119
- await this.restartApiProxy();
128
+ if (!this.apiCheckTimer) {
129
+ this.scheduleApiCheck(server, API_CHECK_INTERVAL_MS);
130
+ }
131
+ }
132
+ /**
133
+ * Check the API, scheduling the next check once this one is done: a rebuild
134
+ * takes as long as an image pull, and two cannot both create the proxy.
135
+ */
136
+ scheduleApiCheck(server, delayMs) {
137
+ this.apiCheckTimer = setTimeout(async () => {
138
+ const startedAt = Date.now();
139
+ try {
140
+ const isAlive = await this.testApiServerOnce(server, API_CHECK_TIMEOUT_MS);
141
+ if (isAlive) {
142
+ this.failedProxyRestarts = 0;
143
+ return;
120
144
  }
121
- }, 60000 * 5); // every 5 minutes
145
+ // Stopped while this check waited, so the proxy is not wanted.
146
+ if (!this.apiCheckTimer)
147
+ return;
148
+ console.log('API proxy is offline, restarting..');
149
+ await this.restartApiProxy();
150
+ this.failedProxyRestarts = 0;
151
+ }
152
+ catch (error) {
153
+ // Nothing awaits this check, so a throw would leave unhandled.
154
+ const message = error?.message ?? String(error);
155
+ const description = `Could not restart the API proxy: ${message}. The node is not reachable until it is rebuilt.`;
156
+ console.error(description);
157
+ void reportError({
158
+ error_type: 'apiProxyRestartFailure',
159
+ error_name: error?.name ?? 'Error',
160
+ error_message: description,
161
+ error_stack: error?.stack ?? '',
162
+ });
163
+ this.endNodeIfProxyIsLost(error);
164
+ }
165
+ finally {
166
+ // Cleared by stopApiCheck, which is how a stopped API stays stopped.
167
+ // The delay is what is left of the interval, so a long rebuild does not
168
+ // push the next check out.
169
+ if (this.apiCheckTimer) {
170
+ this.scheduleApiCheck(server, Math.max(API_CHECK_INTERVAL_MS - (Date.now() - startedAt), API_CHECK_MIN_GAP_MS));
171
+ }
172
+ }
173
+ }, delayMs);
174
+ }
175
+ /**
176
+ * End the process once the proxy counts as lost, so a fresh one can build it:
177
+ * nothing else in this process rebuilds it. A job in flight outranks that,
178
+ * since resuming a flow runs its operations again, and an unreachable registry
179
+ * is not something a new process fixes.
180
+ */
181
+ endNodeIfProxyIsLost(error) {
182
+ if (error?.eventType === 'image-pull-error') {
183
+ return;
122
184
  }
185
+ this.failedProxyRestarts++;
186
+ if (this.failedProxyRestarts < MAX_PROXY_RESTART_FAILURES)
187
+ return;
188
+ if (TaskManagerRegistry.getInstance().size() > 0) {
189
+ console.error('The API proxy could not be rebuilt, waiting for the running job to finish.');
190
+ return;
191
+ }
192
+ console.error('The API proxy could not be rebuilt, restarting the node.');
193
+ process.exit(EXIT_CODES.RESTART);
123
194
  }
124
195
  async registerRoutes() {
125
196
  // Attach require objects to routes
@@ -168,9 +239,9 @@ export class ApiHandler {
168
239
  });
169
240
  }
170
241
  stopApiCheck() {
171
- if (this.apiCheckInterval) {
172
- clearInterval(this.apiCheckInterval);
173
- this.apiCheckInterval = null;
242
+ if (this.apiCheckTimer) {
243
+ clearTimeout(this.apiCheckTimer);
244
+ this.apiCheckTimer = null;
174
245
  }
175
246
  }
176
247
  stopServerAndWebSocket() {
@@ -182,6 +253,7 @@ export class ApiHandler {
182
253
  }
183
254
  }
184
255
  async stop() {
256
+ this.stopApiCheck();
185
257
  await this.stopApiProxy();
186
258
  this.stopServerAndWebSocket();
187
259
  }
@@ -17,6 +17,7 @@ import { setResult, setResults, setHost, setDefaults, setContainerIp, rehydrateE
17
17
  import { configs } from '../../configs/configs.js';
18
18
  import { getSDK } from '../../sdk/index.js';
19
19
  import { pkg } from '../../../static/index.js';
20
+ import { reportError } from '../../monitoring/reportError.js';
20
21
  export const StopReasons = {
21
22
  COMPLETED: 'completed',
22
23
  EXPIRED: 'expired',
@@ -483,7 +484,22 @@ export default class TaskManager {
483
484
  [StopReasons.QUIT]: { ops: Statuses.FAILED, flow: Statuses.FAILED },
484
485
  [StopReasons.UNKNOWN]: { ops: Statuses.FAILED, flow: Statuses.FAILED },
485
486
  };
486
- return map[reason][type];
487
+ const stop = map[reason];
488
+ // A reason outside the known stops, undefined included, reads as unknown.
489
+ // Reported, since it means the caller lost the reason and the flow is about
490
+ // to be recorded as failed on the strength of that.
491
+ if (!stop) {
492
+ const description = `Unknown stop reason '${reason}', reading it as unknown.`;
493
+ console.warn(description);
494
+ void reportError({
495
+ error_type: 'unknownStopReason',
496
+ error_name: 'Error',
497
+ error_message: description,
498
+ error_stack: new Error(description).stack ?? '',
499
+ });
500
+ return map[StopReasons.UNKNOWN][type];
501
+ }
502
+ return stop[type];
487
503
  }
488
504
  getOpStateIndex(opId) {
489
505
  const index = this.operations.findIndex((op) => op.id === opId);
@@ -44,6 +44,8 @@ export declare class TaskManagerRegistry {
44
44
  register(jobId: string, manager: TaskManager): void;
45
45
  get(jobId: string): TaskManager | undefined;
46
46
  remove(jobId: string): void;
47
+ /** Jobs with a task manager, which is to say the jobs this node is running. */
48
+ size(): number;
47
49
  has(jobId: string): boolean;
48
50
  stop(): Promise<void>;
49
51
  }
@@ -54,6 +54,10 @@ export class TaskManagerRegistry {
54
54
  remove(jobId) {
55
55
  this.registry.delete(jobId);
56
56
  }
57
+ /** Jobs with a task manager, which is to say the jobs this node is running. */
58
+ size() {
59
+ return this.registry.size;
60
+ }
57
61
  has(jobId) {
58
62
  return this.registry.has(jobId);
59
63
  }
@@ -220,17 +220,19 @@ export async function runTaskManagerOperation(flow, op, dependent) {
220
220
  : exitCode === 0
221
221
  ? Statuses.SUCCESS
222
222
  : Statuses.FAILED;
223
- const opState = this.repository.getOpState(this.job, index);
223
+ // Empty once the flow is gone, so results are still passed to whatever
224
+ // depends on this operation.
225
+ const logs = this.repository.getOpState(this.job, index)?.logs ?? [];
224
226
  const results = op.results
225
227
  ? createResultsObject(op.results)
226
228
  : undefined;
227
229
  if (results && op.results) {
228
- for (const logObj of opState.logs) {
230
+ for (const logObj of logs) {
229
231
  extractResultFromLog(results, logObj, op.results);
230
232
  }
231
233
  }
232
234
  this.repository.updateOpState(this.job, index, {
233
- logs: opState.logs,
235
+ logs,
234
236
  results,
235
237
  exitCode,
236
238
  endTime: Date.now(),
@@ -272,12 +274,14 @@ export async function runTaskManagerOperation(flow, op, dependent) {
272
274
  else {
273
275
  emitter.emit('log', 'Operation Completed', 'info');
274
276
  }
275
- const opState = this.repository.getOpState(this.job, index);
277
+ // Empty once the flow is gone, so results are still passed to whatever
278
+ // depends on this operation.
279
+ const logs = this.repository.getOpState(this.job, index)?.logs ?? [];
276
280
  const results = op.results
277
281
  ? createResultsObject(op.results)
278
282
  : undefined;
279
283
  if (results && op.results) {
280
- for (const logObj of opState.logs) {
284
+ for (const logObj of logs) {
281
285
  extractResultFromLog(results, logObj, op.results);
282
286
  }
283
287
  }
@@ -298,7 +302,7 @@ export async function runTaskManagerOperation(flow, op, dependent) {
298
302
  }
299
303
  this.repository.updateOpState(this.job, index, {
300
304
  results,
301
- logs: opState.logs,
305
+ logs,
302
306
  exitCode: 2,
303
307
  status,
304
308
  endTime: Date.now(),
@@ -100,7 +100,7 @@ export async function stopTaskManagerOperation(group, opId) {
100
100
  this.operationStatus.set(opId, OperationProgressStatuses.STOPPED);
101
101
  const index = this.getOpStateIndex(opId);
102
102
  const opState = this.repository.getOpState(this.job, index);
103
- const alreadyEnded = !!opState.endTime;
103
+ const alreadyEnded = !!opState?.endTime;
104
104
  if (!alreadyEnded) {
105
105
  this.repository.updateOpState(this.job, index, {
106
106
  status: this.getStatus(StopReasons.STOPPED, 'ops'),
@@ -51,10 +51,16 @@ export class Provider {
51
51
  await this.containerOrchestration.createNetwork(networkName);
52
52
  const networks = {};
53
53
  networks[networkName] = {};
54
- this.proxyStartupAbortController = new AbortController();
55
- await this.containerOrchestration.pullImage(this.frpcImage, undefined, this.proxyStartupAbortController);
54
+ const startup = new AbortController();
55
+ this.proxyStartupAbortController = startup;
56
+ await this.containerOrchestration.pullImage(this.frpcImage, undefined, startup);
56
57
  this.resourceManager.images.setImage(this.frpcImage);
57
58
  const doesFrpcExist = await this.containerOrchestration.doesContainerExist(frpc_name);
59
+ // Stopped while the image was being pulled, so the proxy is not wanted:
60
+ // creating it now would put back what the caller has taken down.
61
+ if (startup.signal.aborted) {
62
+ throw new Error('API proxy setup was stopped');
63
+ }
58
64
  if (!doesFrpcExist) {
59
65
  await this.containerOrchestration.runFlowContainer(this.frpcImage, {
60
66
  name: 'frpc-api-' + address,
@@ -113,6 +119,12 @@ export class Provider {
113
119
  });
114
120
  }
115
121
  }
122
+ // The runtime carries a create through to the end, so a setup stopped
123
+ // during one takes the container back off rather than leaving it up.
124
+ if (startup.signal.aborted) {
125
+ await this.containerOrchestration.stopAndDeleteContainer(frpc_name);
126
+ throw new Error('API proxy setup was stopped');
127
+ }
116
128
  }
117
129
  catch (error) {
118
130
  throw error;
@@ -235,7 +235,15 @@ export class DockerContainerOrchestration {
235
235
  const container = await this.docker.createContainer({
236
236
  ...createDockerRunOptions(image, args, this.gpu),
237
237
  });
238
- await container.start();
238
+ try {
239
+ await container.start();
240
+ }
241
+ catch (error) {
242
+ // A created container holds its name whether it ran or not, and nothing
243
+ // else can take that name until it is gone.
244
+ await this.stopAndDeleteContainer(container.id);
245
+ throw error;
246
+ }
239
247
  return container;
240
248
  }
241
249
  catch (error) {
@@ -67,8 +67,11 @@ export class PodmanContainerOrchestration extends DockerContainerOrchestration {
67
67
  return container;
68
68
  }
69
69
  else {
70
- throw new Error('Cannot start container: ' +
71
- (await start.json()).message);
70
+ const message = (await start.json()).message;
71
+ // A created container holds its name whether it ran or not, and
72
+ // nothing else can take that name until it is gone.
73
+ await this.stopAndDeleteContainer(createResult.Id);
74
+ throw new Error('Cannot start container: ' + message);
72
75
  }
73
76
  }
74
77
  error = await create.json();
@@ -4,6 +4,17 @@ import type { NodeDb, ResourceHistory, VolumeResource } from '../db/index.js';
4
4
  export declare class NodeRepository {
5
5
  private db;
6
6
  constructor(db: LowSync<NodeDb>);
7
+ private lastPersistError?;
8
+ private lastPersistReportAt;
9
+ /**
10
+ * Write the database to disk, reporting rather than throwing when it cannot.
11
+ *
12
+ * lowdb writes synchronously, so a disk fault throws into container handlers,
13
+ * where it escapes uncaught, and into the awaited flow paths, where it fails
14
+ * the operation. The in-memory update is already applied, so only durability
15
+ * depends on this.
16
+ */
17
+ private persist;
7
18
  getFlow(id: string): Flow;
8
19
  getFlows(): {
9
20
  [key: string]: Flow;
@@ -21,7 +32,11 @@ export declare class NodeRepository {
21
32
  status?: string;
22
33
  error: Error | unknown;
23
34
  }): void;
24
- getOpState(id: string, index: number): OpState;
35
+ /** Undefined once the flow is gone: container handlers outlive it. */
36
+ private opStateFor;
37
+ /** The diagnostics, filled in for an op state stored without them. */
38
+ private diagnosticsFor;
39
+ getOpState(id: string, index: number): OpState | undefined;
25
40
  updateOpState(id: string, opIndex: number, updatedFields: Partial<OpState>): void;
26
41
  updateOpStateExitCode(id: string, opIndex: number, exitCode: number): void;
27
42
  updateOpStateLogs(id: string, opIndex: number, log: any): void;
@@ -1,7 +1,70 @@
1
+ import { reportError } from '../monitoring/reportError.js';
2
+ import { isTerminalGone } from '../utils/tolerateBrokenStdio.js';
1
3
  const MAX_LOGS = 24999;
4
+ /** How often an unchanged persist failure is repeated, rather than per write. */
5
+ const PERSIST_REPORT_INTERVAL_MS = 5 * 60 * 1000;
6
+ /** A dead terminal must not resurrect the throw a caller is containing. */
7
+ function printToOperator(message) {
8
+ try {
9
+ console.error(message);
10
+ }
11
+ catch (error) {
12
+ if (!isTerminalGone(error))
13
+ throw error;
14
+ }
15
+ }
16
+ /**
17
+ * A persist failure and what the operator has to do about this errno. Until it
18
+ * is done the node runs on in-memory state and loses it on restart.
19
+ */
20
+ function describePersistFailure(message) {
21
+ let advice = 'Check that the node data directory is writable.';
22
+ if (message.includes('ENOSPC')) {
23
+ advice =
24
+ 'The disk is full. Free space on the node — job state cannot be saved until then, ' +
25
+ 'and will be lost if the node restarts.';
26
+ }
27
+ else if (message.includes('EROFS')) {
28
+ advice = 'The node data directory is mounted read-only and must be made writable.';
29
+ }
30
+ return `Failed to save node state: ${message}. ${advice}`;
31
+ }
2
32
  export class NodeRepository {
3
33
  constructor(db) {
4
34
  this.db = db;
35
+ this.lastPersistReportAt = 0;
36
+ }
37
+ /**
38
+ * Write the database to disk, reporting rather than throwing when it cannot.
39
+ *
40
+ * lowdb writes synchronously, so a disk fault throws into container handlers,
41
+ * where it escapes uncaught, and into the awaited flow paths, where it fails
42
+ * the operation. The in-memory update is already applied, so only durability
43
+ * depends on this.
44
+ */
45
+ persist() {
46
+ try {
47
+ this.db.write();
48
+ this.lastPersistError = undefined;
49
+ }
50
+ catch (error) {
51
+ const message = error?.message ?? String(error);
52
+ const now = Date.now();
53
+ const isRepeat = message === this.lastPersistError &&
54
+ now - this.lastPersistReportAt < PERSIST_REPORT_INTERVAL_MS;
55
+ if (isRepeat)
56
+ return;
57
+ this.lastPersistError = message;
58
+ this.lastPersistReportAt = now;
59
+ const description = describePersistFailure(message);
60
+ printToOperator(description);
61
+ void reportError({
62
+ error_type: 'persistFailure',
63
+ error_name: error?.name ?? 'Error',
64
+ error_message: description,
65
+ error_stack: error?.stack ?? '',
66
+ });
67
+ }
5
68
  }
6
69
  getFlow(id) {
7
70
  return this.db.data.flows[id];
@@ -11,32 +74,35 @@ export class NodeRepository {
11
74
  }
12
75
  setflow(id, flow) {
13
76
  this.db.data.flows[id] = flow;
14
- this.db.write();
77
+ this.persist();
15
78
  }
16
79
  deleteflow(id) {
17
80
  delete this.db.data.flows[id];
18
- this.db.write();
81
+ this.persist();
19
82
  }
20
83
  addOpstate(id, opstate) {
21
- this.db.data.flows[id].state.opStates.push(opstate);
22
- this.db.write();
84
+ const opStates = this.db.data.flows[id]?.state?.opStates;
85
+ if (!opStates)
86
+ return;
87
+ opStates.push(opstate);
88
+ this.persist();
23
89
  }
24
90
  getFlowState(id) {
25
91
  return this.db.data.flows[id]?.state;
26
92
  }
27
93
  updateflowState(id, updatedFields) {
28
- Object.assign(this.db.data.flows[id].state, updatedFields);
29
- this.db.write();
94
+ const state = this.db.data.flows[id]?.state;
95
+ if (!state)
96
+ return;
97
+ Object.assign(state, updatedFields);
98
+ this.persist();
30
99
  }
31
100
  updateflowStateSecret(id, updatedFields) {
32
- if (!this.db.data.flows[id]?.state?.secrets) {
33
- this.db.data.flows[id].state.secrets = {};
34
- }
35
- this.db.data.flows[id].state.secrets = {
36
- ...this.db.data.flows[id].state.secrets,
37
- ...updatedFields,
38
- };
39
- this.db.write();
101
+ const state = this.db.data.flows[id]?.state;
102
+ if (!state)
103
+ return;
104
+ state.secrets = { ...state.secrets, ...updatedFields };
105
+ this.persist();
40
106
  }
41
107
  getFlowSecret(id, key) {
42
108
  const secrets = this.db.data.flows[id]?.state?.secrets ?? {};
@@ -52,23 +118,46 @@ export class NodeRepository {
52
118
  this.db.data.flows[id].state.errors = [];
53
119
  }
54
120
  this.db.data.flows[id].state.errors.push(error instanceof Error ? error.message : error);
55
- this.db.write();
121
+ this.persist();
122
+ }
123
+ /** Undefined once the flow is gone: container handlers outlive it. */
124
+ opStateFor(id, opIndex) {
125
+ return this.db.data.flows[id]?.state?.opStates?.[opIndex];
126
+ }
127
+ /** The diagnostics, filled in for an op state stored without them. */
128
+ diagnosticsFor(opState) {
129
+ const reason = opState.diagnostics?.reason ?? {
130
+ hostShutDown: false,
131
+ jobStopped: false,
132
+ jobExpired: false,
133
+ };
134
+ opState.diagnostics = { ...opState.diagnostics, reason };
135
+ return opState.diagnostics;
56
136
  }
57
137
  getOpState(id, index) {
58
- return this.db.data.flows[id].state.opStates[index];
138
+ return this.opStateFor(id, index);
59
139
  }
60
140
  updateOpState(id, opIndex, updatedFields) {
61
- Object.assign(this.db.data.flows[id].state.opStates[opIndex], updatedFields);
62
- this.db.write();
141
+ const opState = this.opStateFor(id, opIndex);
142
+ if (!opState)
143
+ return;
144
+ Object.assign(opState, updatedFields);
145
+ this.persist();
63
146
  }
64
147
  updateOpStateExitCode(id, opIndex, exitCode) {
65
- this.db.data.flows[id].state.opStates[opIndex].exitCode = exitCode;
66
- this.db.write();
148
+ const opState = this.opStateFor(id, opIndex);
149
+ if (!opState)
150
+ return;
151
+ opState.exitCode = exitCode;
152
+ this.persist();
67
153
  }
68
154
  updateOpStateLogs(id, opIndex, log) {
69
- const logs = this.db.data.flows[id].state.opStates[opIndex].logs;
155
+ const opState = this.opStateFor(id, opIndex);
156
+ if (!opState)
157
+ return;
158
+ const logs = opState.logs;
70
159
  if (!logs) {
71
- this.db.data.flows[id].state.opStates[opIndex].logs = [log];
160
+ opState.logs = [log];
72
161
  }
73
162
  else {
74
163
  // Trim if already at max capacity
@@ -77,24 +166,34 @@ export class NodeRepository {
77
166
  }
78
167
  logs.push(log);
79
168
  }
80
- this.db.write();
169
+ this.persist();
81
170
  }
82
171
  setOpStateDiagnosticsReason(id, opIndex, reason) {
83
- Object.assign(this.db.data.flows[id].state.opStates[opIndex].diagnostics.reason, reason);
84
- this.db.write();
172
+ const opState = this.opStateFor(id, opIndex);
173
+ if (!opState)
174
+ return;
175
+ const diagnostics = this.diagnosticsFor(opState);
176
+ diagnostics.reason = { ...diagnostics.reason, ...reason };
177
+ this.persist();
85
178
  }
86
179
  setOpStateDiagnosticsState(id, opIndex, state) {
87
- this.db.data.flows[id].state.opStates[opIndex].diagnostics.state = state;
88
- this.db.write();
180
+ const opState = this.opStateFor(id, opIndex);
181
+ if (!opState)
182
+ return;
183
+ this.diagnosticsFor(opState).state = state;
184
+ this.persist();
89
185
  }
90
186
  updateOpStateError(id, opIndex, error) {
91
- const opState = this.db.data.flows[id].state.opStates[opIndex];
187
+ const opState = this.opStateFor(id, opIndex);
188
+ if (!opState)
189
+ return;
190
+ opState.errors ??= [];
92
191
  opState.errors.push(error);
93
- this.db.write();
192
+ this.persist();
94
193
  }
95
194
  updateNodeInfo(updatedFields) {
96
195
  Object.assign(this.db.data.info, updatedFields);
97
- this.db.write();
196
+ this.persist();
98
197
  }
99
198
  getNodeInfo() {
100
199
  return this.db.data.info;
@@ -107,18 +206,18 @@ export class NodeRepository {
107
206
  }
108
207
  createImageResource(image, fields) {
109
208
  this.db.data.resources.images[image] = fields;
110
- this.db.write();
209
+ this.persist();
111
210
  }
112
211
  updateImageResource(image, updatedFields) {
113
212
  if (!this.db.data.resources.images[image]) {
114
213
  this.createImageResource(image, updatedFields);
115
214
  }
116
215
  Object.assign(this.db.data.resources.images[image], updatedFields);
117
- this.db.write();
216
+ this.persist();
118
217
  }
119
218
  deleteImageResource(image) {
120
219
  delete this.db.data.resources.images[image];
121
- this.db.write();
220
+ this.persist();
122
221
  }
123
222
  getVolumesResources() {
124
223
  return this.db.data.resources.volumes;
@@ -128,18 +227,18 @@ export class NodeRepository {
128
227
  }
129
228
  createVolumeResource(volume, fields) {
130
229
  this.db.data.resources.volumes[volume] = fields;
131
- this.db.write();
230
+ this.persist();
132
231
  }
133
232
  updateVolumeResource(volume, updatedFields) {
134
233
  if (!this.db.data.resources.volumes[volume]) {
135
234
  this.createVolumeResource(volume, updatedFields);
136
235
  }
137
236
  Object.assign(this.db.data.resources.volumes[volume], updatedFields);
138
- this.db.write();
237
+ this.persist();
139
238
  }
140
239
  deleteVolumeResource(volume) {
141
240
  delete this.db.data.resources.volumes[volume];
142
- this.db.write();
241
+ this.persist();
143
242
  }
144
243
  displayLog(log) {
145
244
  return log;
@@ -0,0 +1,13 @@
1
+ /// <reference types="node" />
2
+ import type { Writable } from 'stream';
3
+ /** Whether a write failed because the terminal went away. */
4
+ export declare function isTerminalGone(error: unknown): boolean;
5
+ /**
6
+ * Ignore writes that fail because the terminal went away — a detached
7
+ * `docker run -t`, a dropped SSH session.
8
+ *
9
+ * tty writes complete after the call returns, and a stream with no `error`
10
+ * listener rethrows, so the failure lands as an uncaughtException wherever the
11
+ * event loop is. Any other stream error is rethrown.
12
+ */
13
+ export declare function tolerateBrokenStdio(streams?: Writable[]): void;
@@ -0,0 +1,23 @@
1
+ /** Errnos that mean the far end is gone rather than the stream misused. */
2
+ const TERMINAL_GONE = new Set(['EIO', 'EPIPE', 'ERR_STREAM_DESTROYED']);
3
+ /** Whether a write failed because the terminal went away. */
4
+ export function isTerminalGone(error) {
5
+ return TERMINAL_GONE.has(error?.code ?? '');
6
+ }
7
+ /**
8
+ * Ignore writes that fail because the terminal went away — a detached
9
+ * `docker run -t`, a dropped SSH session.
10
+ *
11
+ * tty writes complete after the call returns, and a stream with no `error`
12
+ * listener rethrows, so the failure lands as an uncaughtException wherever the
13
+ * event loop is. Any other stream error is rethrown.
14
+ */
15
+ export function tolerateBrokenStdio(streams = [process.stdout, process.stderr]) {
16
+ for (const stream of streams) {
17
+ stream.on('error', (error) => {
18
+ if (isTerminalGone(error))
19
+ return;
20
+ throw error;
21
+ });
22
+ }
23
+ }
package/dist/src/index.js CHANGED
@@ -2,6 +2,9 @@
2
2
  /// <reference path="./global.d.ts" />
3
3
  import { pkg } from './static/index.js';
4
4
  import { startCLI } from './cli/index.js';
5
+ import { tolerateBrokenStdio } from './NodeManager/utils/tolerateBrokenStdio.js';
5
6
  const VERSION = pkg.version;
7
+ // Before anything can write: the first console call happens inside startCLI.
8
+ tolerateBrokenStdio();
6
9
  startCLI(VERSION);
7
10
  export * from './cli/createNosanaCli.js';
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.47-rc",
3
+ "version": "1.1.48-rc",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@nosana/node",
9
- "version": "1.1.47-rc",
9
+ "version": "1.1.48-rc",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@coral-xyz/anchor": "^0.28.1-beta.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.47-rc",
3
+ "version": "1.1.48-rc",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "nosana-node": "./dist/src/index.js"