@torrent-tv/proxy 2.96.14 → 2.97.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.
package/bin/cli.js CHANGED
@@ -88,6 +88,7 @@ program
88
88
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
89
89
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
90
90
  .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
91
+ .option("--profile-start <seconds>", "Profile the main thread's CPU for this many seconds from the first source opened, written beside the state (off by default)")
91
92
  .option(
92
93
  "--send-chunk-bytes <bytes>",
93
94
  "Size of one data-channel message when sending a response body (0 = whatever the body read hands over, ~64KB). For measuring whether the delivery wedge depends on that size."
@@ -392,6 +393,7 @@ try {
392
393
  budgetPolicy,
393
394
  logFile: options.logFile ?? "",
394
395
  deliverySink: options.deliverySink === true,
396
+ profileStartSeconds: Number(options.profileStart) || 0,
395
397
  // Late-bound the same way `webRtcManager` is below: the torrent pool is
396
398
  // built inside `startProxyServer`, before `dataChannelHandler` — the
397
399
  // thing that actually owns a channel to push down — exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.96.14",
3
+ "version": "2.97.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -36,6 +36,19 @@ export function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }
36
36
  refused({ token, fileIndex, start: range.start, runTag: input.runTag ?? null });
37
37
  return reply.code(503).send({ error: "Original input bytes are outside the admitted ranges." });
38
38
  }
39
+ // What was handed to the system for this answer before its connection
40
+ // closed, and whether all of it was: an answer runs to the end of its
41
+ // admitted range and FFmpeg closes the connection once it has what it wants,
42
+ // so the range answered says nothing about the bytes taken. What FFmpeg read
43
+ // is at most what was handed over; the kernel's buffer is the difference,
44
+ // and it cannot be seen from here (torrent-tv/meta#177).
45
+ const socket = req.raw?.socket;
46
+ const writtenBefore = socket?.bytesWritten ?? 0;
47
+ reply.raw?.once?.("close", () => {
48
+ input.noteAnswer?.({ fileIndex, start: range.start, end: held.end,
49
+ written: Math.max(0, (socket?.bytesWritten ?? writtenBefore) - writtenBefore),
50
+ handedOver: reply.raw?.writableFinished === true });
51
+ });
39
52
  return reply.code(206).header("Content-Length", String(held.bytes.length))
40
53
  .header("Content-Range", `bytes ${range.start}-${held.end}/${length}`).send(held.bytes);
41
54
  }
package/server.js CHANGED
@@ -80,6 +80,8 @@ import { measureStartAndStop } from "./services/encode/start-stop-cost.js";
80
80
  import { benchmarkAudio } from "./services/encode/audio-calibration.js";
81
81
  import { logger } from "./utils/logger.js";
82
82
  import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
83
+ import { startupCpuProfile } from "./services/storage/cpu-profile.js";
84
+ import { startProcessorReport } from "./services/storage/processor-report.js";
83
85
 
84
86
  const require = createRequire(import.meta.url);
85
87
  const { version } = require("./package.json");
@@ -119,6 +121,8 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
119
121
  export async function startProxyServer({
120
122
  host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
121
123
  deliverySink = false,
124
+ // Seconds of main-thread CPU profile from the first source opened; 0 is off.
125
+ profileStartSeconds = 0,
122
126
  // What viewers' connections carry beyond the film, per byte of film; the
123
127
  // transport counts it and is built after this server, so it is late-bound.
124
128
  serviceShare = () => null,
@@ -875,6 +879,22 @@ export async function startProxyServer({
875
879
  await torrentPool.fillFileInBackground?.(torrent, fileIndex);
876
880
  }
877
881
  });
882
+ // Where the processor goes, and at a cold start what the main thread does
883
+ // with it, when the operator asks (torrent-tv/meta#177).
884
+ const startupProfile = startupCpuProfile({
885
+ seconds: profileStartSeconds,
886
+ dir: stateDir || "",
887
+ log: (line) => logger.info(line),
888
+ mayKeep: (bytes) => diagnostics?.mayKeep?.({ what: "a CPU profile", bytes }) ?? true
889
+ });
890
+ startProcessorReport({
891
+ log: (line) => logger.info(line),
892
+ ffmpegPids: () => [...outputParts.outputs.values()]
893
+ .flatMap((output) => outputParts.encodeRuns.liveRunsOf(output))
894
+ .map((run) => run.process?.pid)
895
+ .filter((pid) => Number.isInteger(pid)),
896
+ detailed: () => startupProfile.active()
897
+ });
878
898
  // What the last life of this process left on the disk. The kernel kills this
879
899
  // one often enough for that to be an ordinary state rather than an odd one —
880
900
  // twice in a single viewing on 2026-09-02 — and when it does, no exit handler
@@ -1106,9 +1126,11 @@ export async function startProxyServer({
1106
1126
  );
1107
1127
  // Bytes a browser can time its link with before any film has been chosen.
1108
1128
  app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
1109
- app.post("/api/sources", async (req, reply) =>
1110
- handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers })
1111
- );
1129
+ app.post("/api/sources", async (req, reply) => {
1130
+ // A cold start begins with the first source opened (torrent-tv/meta#177).
1131
+ startupProfile.begin();
1132
+ return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers });
1133
+ });
1112
1134
  app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
1113
1135
  handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
1114
1136
  );
@@ -33,8 +33,13 @@ export class EncodeInputs {
33
33
  #wanted = new Map();
34
34
  /** Bytes per millisecond of recent original-input copies, read where the copy is made. */
35
35
  #copyRates = [];
36
- /** Seconds an encoder took to open its original input, per output. */
37
- #opens = new Map();
36
+ /**
37
+ * How long an encoder takes to open an output's original input: asked of
38
+ * the one owner of start readings (`RunCosts`, torrent-tv/meta#184).
39
+ *
40
+ * @type {(output: object) => { seconds: number, measured: boolean } | null}
41
+ */
42
+ #openOf;
38
43
  #resolve;
39
44
  #read;
40
45
  #heldRanges;
@@ -46,7 +51,9 @@ export class EncodeInputs {
46
51
  #log;
47
52
  #now;
48
53
 
49
- constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true, log = () => {}, now = Date.now }) {
54
+ constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
55
+ openOf = () => null, log = () => {}, now = Date.now }) {
56
+ this.#openOf = openOf;
50
57
  this.#resolve = resolve;
51
58
  this.#read = readRanges;
52
59
  this.#heldRanges = heldRanges;
@@ -130,10 +137,26 @@ export class EncodeInputs {
130
137
  return null;
131
138
  }
132
139
 
133
- retain(output, windows) {
140
+ /**
141
+ * Withdraw what this output no longer needs: a request no wanted window
142
+ * covers, and — when `starts` is given — a copy already held whose start is
143
+ * neither where a live run ends nor where one is being started. A next
144
+ * stretch is copied for the run working before it (torrent-tv/meta#158);
145
+ * once that run has gone and the plan starts elsewhere, after a seek, the
146
+ * copy held memory nobody would use (torrent-tv/meta#184).
147
+ *
148
+ * @param {object} output
149
+ * @param {{ from: number, to: number }[]} windows
150
+ * @param {Set<number> | null} [starts]
151
+ */
152
+ retain(output, windows, starts = null) {
134
153
  for (const [key, request] of this.#requests) {
135
- if (request.output !== output || windows.some(window => request.from <= window.to && request.to >= window.from)) continue;
136
- this.#note(request, "withdrawn: no wanted window covers it");
154
+ if (request.output !== output) continue;
155
+ const covered = windows.some(window => request.from <= window.to && request.to >= window.from);
156
+ const unused = starts !== null && request.result?.kind === "result" && !starts.has(request.from);
157
+ if (covered && !unused) continue;
158
+ this.#note(request, covered ? "withdrawn: no live run ends before it and no start is placed there"
159
+ : "withdrawn: no wanted window covers it");
137
160
  this.#requests.delete(key);
138
161
  this.#wanted.delete(key);
139
162
  request.result?.release?.();
@@ -154,16 +177,13 @@ export class EncodeInputs {
154
177
  let existing = this.#requests.get(key);
155
178
  if (existing && existing.to !== to) {
156
179
  // The plan's bound for this start moved. A copy already made beyond it
157
- // would be a run over somebody else's stretch, so it is given back; one
158
- // being made is checked when it is done, and one waiting for bytes or
159
- // memory takes the new bound when its own event prepares it again.
180
+ // holds the shorter stretch whole, so the run is commanded over its
181
+ // prefix and the copy is kept until the run ends: copying it again cost
182
+ // as long as the first copy (torrent-tv/meta#184). One being made is
183
+ // narrowed when it is done, and one waiting for bytes or memory takes the
184
+ // new bound when its own event prepares it again.
160
185
  existing.to = to;
161
- if (existing.result?.kind === "result" && existing.result.to > to) {
162
- this.#requests.delete(key);
163
- this.#wanted.delete(key);
164
- existing.result.release();
165
- existing = undefined;
166
- }
186
+ if (existing.result?.kind === "result" && existing.result.to > to) existing.result.to = Math.max(existing.from, to);
167
187
  }
168
188
  if (existing?.result?.kind === "result") {
169
189
  this.#requests.delete(key);
@@ -200,44 +220,38 @@ export class EncodeInputs {
200
220
  this.#wanted.delete(key);
201
221
  request.result?.release?.();
202
222
  }
203
- this.#opens.delete(addressOf(output));
204
223
  }
205
224
 
206
225
  /**
207
- * How long an encoder took to open this output's original input: from its
208
- * spawn to the name of its first closed piece, less what encoding that piece
209
- * costs. Measured by whoever ran it, kept here where a stretch is sized.
226
+ * How many bytes a stretch may hold so that copying it takes no longer than
227
+ * opening the input it replaces: the recent copy rate times this output's
228
+ * recent open. Beyond it the open's share of each piece falls below the
229
+ * copy's, which is the same for every piece; a longer stretch buys less and
230
+ * delays the run's start more. Null until a copy rate is measured and an
231
+ * open is known, and the run then gets one piece. An output that has not
232
+ * opened yet is sized by the host's startup reading, which is said: at a
233
+ * cold start the second run was one piece too for want of it, and cost
234
+ * another open (6.34 s, torrent-tv/meta#166 run 12).
210
235
  *
211
236
  * @param {object} output
212
- * @param {number} seconds
237
+ * @returns {{ bytes: number, openMeasured: boolean } | null}
213
238
  */
214
- noteOpen(output, seconds) {
215
- if (!Number.isFinite(seconds) || seconds < 0) return;
216
- const readings = this.#opens.get(addressOf(output)) ?? [];
217
- keep(readings, seconds);
218
- this.#opens.set(addressOf(output), readings);
239
+ stretchFor(output) {
240
+ const rate = middleOf(this.#copyRates);
241
+ const open = this.#openOf(output);
242
+ return rate === null || !open ? null : { bytes: Math.floor(rate * open.seconds * 1000), openMeasured: open.measured };
219
243
  }
220
244
 
221
245
  /**
222
- * How many bytes a stretch may hold so that copying it takes no longer than
223
- * opening the input it replaces: the recent copy rate times this output's
224
- * recent open. Beyond it the open's share of each piece falls below the
225
- * copy's, which is the same for every piece; a longer stretch buys less and
226
- * delays the run's start more. Null until both have been measured, and the
227
- * run then gets one piece, as it did before either reading existed.
228
- *
229
246
  * @param {object} output
230
247
  * @returns {number | null}
231
248
  */
232
249
  stretchBytesFor(output) {
233
- const rate = middleOf(this.#copyRates);
234
- const open = middleOf(this.#opens.get(addressOf(output)) ?? []);
235
- return rate === null || open === null ? null : Math.floor(rate * open * 1000);
250
+ return this.stretchFor(output)?.bytes ?? null;
236
251
  }
237
252
 
238
253
  #prepare(request) {
239
254
  request.pending = true;
240
- request.stale = false;
241
255
  request.revision = this.#revision;
242
256
  request.memoryRevision = this.#memoryRevision;
243
257
  // Where the time from the request to its input goes, across every attempt:
@@ -260,13 +274,9 @@ export class EncodeInputs {
260
274
  if (result.kind === "result") Object.assign(result, { from: request.from, to: request.from });
261
275
  }
262
276
  if (this.#requests.get(request.key) !== request) { result.release?.(); return; }
263
- if (result.kind === "result" && result.to > request.to) {
264
- // The plan narrowed this start while the copy was being made.
265
- result.release();
266
- request.result = null;
267
- request.stale = true;
268
- return;
269
- }
277
+ // The plan narrowed this start while the copy was being made: the run
278
+ // is commanded over its prefix.
279
+ if (result.kind === "result" && result.to > request.to) result.to = Math.max(request.from, request.to);
270
280
  request.result = result;
271
281
  this.#note(request, result.kind === "result" ? `ready #${result.from}..#${result.to}, ${result.bytes ?? "?"} bytes` +
272
282
  (Number.isFinite(result.copyMs) ? `, copied in ${result.copyMs}ms` : "")
@@ -281,8 +291,8 @@ export class EncodeInputs {
281
291
  }).finally(() => {
282
292
  request.pending = false;
283
293
  if (request.result?.kind !== "needs-memory") this.#wanted.delete(request.key);
284
- const changed = request.stale || (request.result?.kind === "needs-memory"
285
- ? request.memoryRevision !== this.#memoryRevision : request.revision !== this.#revision);
294
+ const changed = request.result?.kind === "needs-memory"
295
+ ? request.memoryRevision !== this.#memoryRevision : request.revision !== this.#revision;
286
296
  if (this.#requests.get(request.key) === request && changed &&
287
297
  request.result?.kind !== "result" && request.result?.kind !== "terminal") this.#prepare(request);
288
298
  });
@@ -360,7 +370,9 @@ export class EncodeInputs {
360
370
  }
361
371
  return good;
362
372
  };
363
- const target = this.stretchBytesFor(output);
373
+ const sizing = this.stretchFor(output);
374
+ const target = sizing?.bytes ?? null;
375
+ if (sizing && !sizing.openMeasured) request.timing.openApproximate = true;
364
376
  let end = target === null || this.#heldRanges === null ? from : await longest(target);
365
377
  if (this.#requests.get(request.key) !== request) return undefined;
366
378
  this.#wanted.set(request.key, { wanted: stretches.get(end).bytes, required: minimum });
@@ -429,7 +441,8 @@ function describeTiming(timing, now) {
429
441
  return `; asked ${Math.max(0, now - timing.askedAt)}ms ago over ${timing.attempts} attempt(s): ` +
430
442
  `${timing.resolutions} resolution(s) ${timing.resolveMs}ms ` +
431
443
  `(${timing.reads} file read(s): queued ${timing.queuedMs}ms, reading ${timing.readMs}ms), ` +
432
- `held ranges ${timing.heldMs}ms, budget ${timing.budgetMs}ms`;
444
+ `held ranges ${timing.heldMs}ms, budget ${timing.budgetMs}ms` +
445
+ (timing.openApproximate ? "; sized by the host's startup open, this output has not opened yet" : "");
433
446
  }
434
447
 
435
448
  function addressOf(output) { return output.outputKey ?? output.id; }
@@ -145,6 +145,30 @@ export class EncodeOrchestrator {
145
145
  this.#costs.noteStartup(measured);
146
146
  }
147
147
 
148
+ /**
149
+ * One output's original-input open, and what sizes its stretch: the one
150
+ * owner of start readings is this class's `RunCosts` (torrent-tv/meta#184).
151
+ *
152
+ * @param {string} address
153
+ * @param {number} seconds
154
+ */
155
+ noteOpen(address, seconds) {
156
+ this.#costs.noteOpen(address, seconds);
157
+ }
158
+
159
+ /**
160
+ * @param {string} address
161
+ * @returns {{ seconds: number, measured: boolean } | null}
162
+ */
163
+ openOf(address) {
164
+ return this.#costs.openOf(address);
165
+ }
166
+
167
+ /** @param {string} address */
168
+ forgetOpens(address) {
169
+ this.#costs.forgetOpens(address);
170
+ }
171
+
148
172
  /**
149
173
  * What a stop and a start cost on this host, in seconds, as the plan reads
150
174
  * them — for anyone else pricing a fresh encoder by the same figures.
@@ -289,41 +313,6 @@ export class EncodeOrchestrator {
289
313
  return coverage;
290
314
  }
291
315
 
292
- /**
293
- * Where a run started here must stop: the free stretch in front of it.
294
- *
295
- * Asked of the one map, brought up to date first. It used to be worked out by
296
- * the session manager, which reached into this layer for the map and into the
297
- * store for what was on the disk and put the two together itself — one fact
298
- * with two owners and a third party carrying it between them, which is how the
299
- * two came to disagree.
300
- *
301
- * @param {object} params
302
- * @param {string} params.address
303
- * @param {number} params.from - Where the run will start.
304
- * @param {object | null} [params.exceptRun] - The run being replaced, whose
305
- * own claim is not somebody else's material.
306
- * @param {number} [params.segmentCount] - The output's length, when known.
307
- * @returns {number} The last number to work through, or `-1` for the end of
308
- * the film.
309
- */
310
- freeStretchEnd({ address, from, exceptRun = null, segmentCount = 0 }) {
311
- if (!address) {
312
- return -1;
313
- }
314
- if (segmentCount > 0) {
315
- this.coverageOf(address).setSegmentCount(segmentCount);
316
- }
317
- const coverage = this.#upToDateCoverage(address);
318
- const start = Math.max(0, from);
319
- const free = coverage.freeRunFrom(start, exceptRun);
320
- if (!Number.isFinite(free)) {
321
- return -1;
322
- }
323
- const end = start + Math.max(1, free) - 1;
324
- return segmentCount > 0 && end >= segmentCount - 1 ? -1 : end;
325
- }
326
-
327
316
  /**
328
317
  * @param {string} address
329
318
  * @returns {import("../encode/EncodeRun.js").EncodeRun[]}
@@ -974,9 +963,11 @@ export class EncodeOrchestrator {
974
963
  // were two steps, and two owners each performed the second one.
975
964
  //
976
965
  // A run whose input is one copied stretch can make nothing past it, so it
977
- // claims that stretch and not the plan's longer bound: the rest stays free
978
- // for the next run (torrent-tv/meta#158). Every other run claims as before.
979
- const through = Number.isInteger(run.inputThrough) ? run.inputThrough : to;
966
+ // claims that stretch — its own `to` — and not the plan's longer bound:
967
+ // the rest stays free for the next run (torrent-tv/meta#158). A run whose
968
+ // input grows a segment at a time claims the plan's bound, as it may reach
969
+ // it (torrent-tv/meta#184).
970
+ const through = run.originalInput ? run.to : to;
980
971
  this.coverageOf(address).claim(run, from, endOfRun({ from, to: through }));
981
972
  }
982
973
 
@@ -64,8 +64,14 @@ const MICROSECONDS_PER_SECOND = 1_000_000;
64
64
  * it finished none.
65
65
  * @property {number} livedMs
66
66
  * @property {number | null} [firstNamedMs] - Spawn to the moment the encoder
67
- * named its first closed piece: the open of its input plus that one piece's
68
- * encoding, without the wait for the following piece that publication adds.
67
+ * named its first closed piece, less the time it was paused before then: the
68
+ * open of its input plus that one piece's encoding, without the wait for the
69
+ * following piece that publication adds.
70
+ * @property {number | null} [firstNamedWallMs] - The same on the wall clock,
71
+ * pauses included: what the viewer waited.
72
+ * @property {number | null} [firstOutputWallMs] - Spawn to the first output on
73
+ * the wall clock; `firstOutputMs` leaves the pauses out.
74
+ * @property {number} [stoppedMs] - How long the run was paused in all.
69
75
  * @property {boolean} normal - Whether this ending is the expected one.
70
76
  * @property {string} lastError - The last thing ffmpeg said on stderr.
71
77
  * @property {string | null} provenName - The last piece this run named while it
@@ -166,6 +172,10 @@ export class EncodeRun {
166
172
  */
167
173
  #firstNamedAt = 0;
168
174
 
175
+ /** How long the run had been paused when each of the two above was taken. */
176
+ #stoppedBeforeFirstOutput = 0;
177
+ #stoppedBeforeFirstNamed = 0;
178
+
169
179
  /** The film made and the run's own working time, as of the last progress report. */
170
180
  #workSample = null;
171
181
 
@@ -569,6 +579,7 @@ export class EncodeRun {
569
579
  this.#produced.add(index);
570
580
  if (this.#firstOutputAt === 0) {
571
581
  this.#firstOutputAt = this.now();
582
+ this.#stoppedBeforeFirstOutput = this.clock.stoppedMs(this.#firstOutputAt);
572
583
  }
573
584
  if (this.#state === ENCODE_RUN_STATE.STARTING) {
574
585
  this.#transition(ENCODE_RUN_EVENT.FIRST_SEGMENT);
@@ -607,7 +618,10 @@ export class EncodeRun {
607
618
  }
608
619
  if (this.#publicationError) break;
609
620
  const index = this.indexOfName(name);
610
- if (this.#firstNamedAt === 0 && Number.isInteger(index) && index >= this.from) this.#firstNamedAt = this.now();
621
+ if (this.#firstNamedAt === 0 && Number.isInteger(index) && index >= this.from) {
622
+ this.#firstNamedAt = this.now();
623
+ this.#stoppedBeforeFirstNamed = this.clock.stoppedMs(this.#firstNamedAt);
624
+ }
611
625
  const endOfWork = Number.isFinite(endOfRun(this)) ? this.to : this.lastSegmentIndex();
612
626
  // A packet crossing the final cut can flush a following file. It may
613
627
  // complete its predecessor, but no unadmitted interval can be published.
@@ -920,11 +934,18 @@ export class EncodeRun {
920
934
  // Null where the run was never told to stop, or never produced anything:
921
935
  // an absent measurement says so rather than reading as zero.
922
936
  dyingMs: this.#stopOrderedAt > 0 ? this.now() - this.#stopOrderedAt : null,
937
+ // Without the time this run was paused before then: a pause is our own
938
+ // decision, not what starting cost (torrent-tv/meta#184). The wall-clock
939
+ // figures beside them are what the viewer waited.
923
940
  firstOutputMs:
924
941
  this.#firstOutputAt > 0 && this.#startedAt > 0
925
- ? this.#firstOutputAt - this.#startedAt
942
+ ? this.#firstOutputAt - this.#startedAt - this.#stoppedBeforeFirstOutput
926
943
  : null,
927
- firstNamedMs: this.#firstNamedAt > 0 && this.#startedAt > 0 ? this.#firstNamedAt - this.#startedAt : null,
944
+ firstOutputWallMs: this.#firstOutputAt > 0 && this.#startedAt > 0 ? this.#firstOutputAt - this.#startedAt : null,
945
+ firstNamedMs: this.#firstNamedAt > 0 && this.#startedAt > 0
946
+ ? this.#firstNamedAt - this.#startedAt - this.#stoppedBeforeFirstNamed : null,
947
+ firstNamedWallMs: this.#firstNamedAt > 0 && this.#startedAt > 0 ? this.#firstNamedAt - this.#startedAt : null,
948
+ stoppedMs: this.clock.stoppedMs(),
928
949
  normal: ending === ENCODE_EXIT.COMPLETE,
929
950
  lastError: this.lastError
930
951
  };