@nosana/node 1.1.22 → 1.1.23

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.22",
3
+ "version": "1.1.23",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "nosana-node": "./dist/src/index.js"
@@ -1,7 +1,7 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
3
  import type { OperationResults, Log } from '@nosana/sdk';
4
- export declare function extractLogsAndResultsFromLogBuffer(logBuffer: Buffer, operationResults: OperationResults | undefined, expiryTimeout?: number): {
4
+ export declare function extractLogsAndResultsFromLogBuffer(logBuffer: Buffer, operationResults: OperationResults | undefined): {
5
5
  logs: Log[];
6
6
  results: {} | undefined;
7
7
  };
@@ -1,34 +1,14 @@
1
- import { parseBuffer } from '../../provider/utils/parseBuffer.js';
1
+ import { parseLogFrames } from '../../provider/utils/parseBuffer.js';
2
2
  import { createResultsObject, extractResultFromLog, } from './extractResultsFromLogs.js';
3
- export function extractLogsAndResultsFromLogBuffer(logBuffer, operationResults, expiryTimeout = 180) {
4
- const logs = [];
3
+ export function extractLogsAndResultsFromLogBuffer(logBuffer, operationResults) {
5
4
  const results = operationResults
6
5
  ? createResultsObject(operationResults)
7
6
  : undefined;
8
- let index = 0, running = true;
9
- const timer = setTimeout(() => {
10
- running = false;
11
- logs.push({
12
- type: 'nodeerr',
13
- log: 'Took too long to retrive all logs',
14
- timestamp: new Date().toISOString(),
15
- });
16
- }, expiryTimeout);
17
- while (index < logBuffer.length && running) {
18
- const log = parseBuffer(logBuffer, index);
19
- logs.push(log);
20
- if (results && operationResults) {
7
+ const { logs } = parseLogFrames(logBuffer);
8
+ if (results && operationResults) {
9
+ for (const log of logs) {
21
10
  extractResultFromLog(results, log, operationResults);
22
11
  }
23
- if (logs.length >= 25000) {
24
- running = false;
25
- logs.push({
26
- type: 'nodeerr',
27
- log: 'Found too many logs...',
28
- timestamp: new Date().toISOString(),
29
- });
30
- }
31
12
  }
32
- clearTimeout(timer);
33
13
  return { logs, results };
34
14
  }
@@ -11,6 +11,7 @@ export declare class ContainerStateManager {
11
11
  private state;
12
12
  private exitedCheckCount;
13
13
  private lastLogTimestamp;
14
+ private logRemainder;
14
15
  private readonly EXITED_CHECKS_REQUIRED;
15
16
  private currentLogStream;
16
17
  private currentStatsStream;
@@ -20,6 +21,19 @@ export declare class ContainerStateManager {
20
21
  startMonitoring(): Promise<void>;
21
22
  private attachLogStream;
22
23
  waitForExit(): Promise<void>;
24
+ /**
25
+ * Wait for the followed log stream to finish delivering its final buffered
26
+ * frames after the container has exited.
27
+ *
28
+ * `container.wait()` (which drives `waitForExit`) and the log stream are
29
+ * independent connections, so when `wait()` reports the exit the stream may
30
+ * still be flushing the last lines (e.g. an op's final result line). Reading
31
+ * logs or tearing the stream down at that point loses them. The stream's
32
+ * `close` handler nulls `currentLogStream`, and it won't re-attach once the
33
+ * container has exited, so a null stream means the flush is complete. Bounded
34
+ * so a stream that never closes can't stall completion.
35
+ */
36
+ waitForLogsDrained(timeoutMs?: number): Promise<void>;
23
37
  stopMonitoring(): void;
24
38
  private readonly STATS_INTERVAL_MS;
25
39
  private attachStatsStream;
@@ -1,6 +1,6 @@
1
1
  import { Readable } from 'stream';
2
2
  import { createInterface } from 'readline';
3
- import { parseBuffer } from './utils/parseBuffer.js';
3
+ import { parseLogFrames } from './utils/parseBuffer.js';
4
4
  import { parseDockerStat } from './utils/parseDockerStat.js';
5
5
  import { liveAbortSignal } from '../utils/liveAbortSignal.js';
6
6
  function destroyStream(stream) {
@@ -20,6 +20,7 @@ export class ContainerStateManager {
20
20
  this.state = 'starting';
21
21
  this.exitedCheckCount = 0;
22
22
  this.lastLogTimestamp = 0; // Unix timestamp in seconds from actual container logs
23
+ this.logRemainder = Buffer.alloc(0); // trailing partial frame between chunks
23
24
  this.EXITED_CHECKS_REQUIRED = 3; // Require 3 consecutive checks before confirming exit
24
25
  this.currentLogStream = null;
25
26
  this.currentStatsStream = null;
@@ -78,6 +79,9 @@ export class ContainerStateManager {
78
79
  // container we're tearing down. Mirrors `attachStatsStream`.
79
80
  if (this.controller.signal.aborted)
80
81
  return;
82
+ // Any partial frame buffered from a previous stream can never be completed
83
+ // by this freshly re-attached stream, so start clean.
84
+ this.logRemainder = Buffer.alloc(0);
81
85
  try {
82
86
  this.currentLogStream = await this.container.logs({
83
87
  stdout: true,
@@ -88,11 +92,18 @@ export class ContainerStateManager {
88
92
  abortSignal: liveAbortSignal(this.controller.signal),
89
93
  });
90
94
  this.currentLogStream.on('data', (data) => {
91
- const { log, type, timestamp } = parseBuffer(data);
92
- if (!log)
93
- return;
94
- this.lastLogTimestamp = Math.floor(new Date(timestamp).getTime() / 1000);
95
- this.emitter.emit('log', log, type, 'container', timestamp);
95
+ // A single 'data' event can carry several multiplexed frames (Docker
96
+ // coalesces output, especially the final flush at container exit) and
97
+ // can split one frame across events. Decode every complete frame and
98
+ // carry any partial trailing frame over to the next chunk.
99
+ const { logs, rest } = parseLogFrames(Buffer.concat([this.logRemainder, data]));
100
+ this.logRemainder = rest;
101
+ for (const { log, type, timestamp } of logs) {
102
+ if (!log)
103
+ continue;
104
+ this.lastLogTimestamp = Math.floor(new Date(timestamp).getTime() / 1000);
105
+ this.emitter.emit('log', log, type, 'container', timestamp);
106
+ }
96
107
  });
97
108
  this.currentLogStream.on('close', () => {
98
109
  this.currentLogStream = null;
@@ -125,6 +136,26 @@ export class ContainerStateManager {
125
136
  }, 500);
126
137
  });
127
138
  }
139
+ /**
140
+ * Wait for the followed log stream to finish delivering its final buffered
141
+ * frames after the container has exited.
142
+ *
143
+ * `container.wait()` (which drives `waitForExit`) and the log stream are
144
+ * independent connections, so when `wait()` reports the exit the stream may
145
+ * still be flushing the last lines (e.g. an op's final result line). Reading
146
+ * logs or tearing the stream down at that point loses them. The stream's
147
+ * `close` handler nulls `currentLogStream`, and it won't re-attach once the
148
+ * container has exited, so a null stream means the flush is complete. Bounded
149
+ * so a stream that never closes can't stall completion.
150
+ */
151
+ async waitForLogsDrained(timeoutMs = 3000) {
152
+ const deadline = Date.now() + timeoutMs;
153
+ while (this.currentLogStream !== null) {
154
+ if (this.controller.signal.aborted || Date.now() >= deadline)
155
+ return;
156
+ await new Promise((resolve) => setTimeout(resolve, 50));
157
+ }
158
+ }
128
159
  stopMonitoring() {
129
160
  if (this.pollingInterval) {
130
161
  clearInterval(this.pollingInterval);
@@ -266,6 +266,10 @@ export class Provider {
266
266
  emitter.emit('healthcheck:startup:success');
267
267
  }
268
268
  await stateManager.waitForExit();
269
+ // `wait()` reporting the exit and the log stream finishing its final
270
+ // flush are independent; drain the stream before results are read off
271
+ // the logs so the last line (e.g. the op's result) isn't lost.
272
+ await stateManager.waitForLogsDrained();
269
273
  }
270
274
  const info = await promiseTimeoutWrapper(container.inspect({
271
275
  abortSignal: liveAbortSignal(controller.signal),
@@ -1,4 +1,17 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
3
  import { Log } from '@nosana/sdk';
4
+ /**
5
+ * Demultiplex every complete Docker log frame contained in `buffer`.
6
+ *
7
+ * A non-TTY container log stream is a sequence of frames, each an 8-byte header
8
+ * (stream type + big-endian payload length) followed by the payload. The stream
9
+ * layer may pack several frames into one read *and* split a single frame across
10
+ * reads, so a caller following a live stream must feed the returned `rest` (an
11
+ * incomplete trailing frame, if any) back in alongside the next chunk.
12
+ */
13
+ export declare function parseLogFrames(buffer: Buffer): {
14
+ logs: Log[];
15
+ rest: Buffer;
16
+ };
4
17
  export declare function parseBuffer(buffer: Buffer, index?: number): Log;
@@ -1,4 +1,27 @@
1
1
  const TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\s/;
2
+ const DOCKER_LOG_HEADER_BYTES = 8;
3
+ /**
4
+ * Demultiplex every complete Docker log frame contained in `buffer`.
5
+ *
6
+ * A non-TTY container log stream is a sequence of frames, each an 8-byte header
7
+ * (stream type + big-endian payload length) followed by the payload. The stream
8
+ * layer may pack several frames into one read *and* split a single frame across
9
+ * reads, so a caller following a live stream must feed the returned `rest` (an
10
+ * incomplete trailing frame, if any) back in alongside the next chunk.
11
+ */
12
+ export function parseLogFrames(buffer) {
13
+ const logs = [];
14
+ let index = 0;
15
+ while (index + DOCKER_LOG_HEADER_BYTES <= buffer.length) {
16
+ const chunkLength = buffer.readUInt32BE(index + 4);
17
+ const end = index + DOCKER_LOG_HEADER_BYTES + chunkLength;
18
+ if (end > buffer.length)
19
+ break; // frame has not fully arrived yet
20
+ logs.push(parseBuffer(buffer, index));
21
+ index = end;
22
+ }
23
+ return { logs, rest: buffer.subarray(index) };
24
+ }
2
25
  export function parseBuffer(buffer, index = 0) {
3
26
  const head = buffer.subarray(index, (index += 8));
4
27
  const chunkType = head.readUInt8(0);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@nosana/node",
3
- "version": "1.1.22",
3
+ "version": "1.1.23",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@nosana/node",
9
- "version": "1.1.22",
9
+ "version": "1.1.23",
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.22",
3
+ "version": "1.1.23",
4
4
  "description": "",
5
5
  "bin": {
6
6
  "nosana-node": "./dist/src/index.js"