@torrent-tv/proxy 2.58.2 → 2.59.0
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/CHANGELOG.md +12 -0
- package/assets/diagnostics/sctpstate.gdb +29 -0
- package/bin/cli.js +19 -1
- package/package.json +1 -1
- package/services/data-channel-handler.js +36 -4
- package/services/delivery-probe.js +110 -4
- package/services/memory-report.js +166 -0
- package/services/piece-store/shared-piece-store.js +791 -717
- package/services/torrent-worker/worker.js +5 -0
- package/services/usrsctp-state.js +146 -0
- package/test/memory-budget.test.js +69 -0
- package/test/probe-wedge-certainty.test.js +60 -0
- package/test/usrsctp-state.test.js +115 -0
|
@@ -499,7 +499,12 @@ setInterval(() => {
|
|
|
499
499
|
const reads = stats.fromMemory + stats.fromDisk;
|
|
500
500
|
const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
|
|
501
501
|
log(
|
|
502
|
+
// In BYTES as well as in pieces. The count alone says nothing without the
|
|
503
|
+
// piece size, and the piece size differs per torrent: on the film the
|
|
504
|
+
// proxy was killed under, 2026-08-28, "63" meant 504 MB.
|
|
502
505
|
`piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
|
|
506
|
+
`(${Math.round((stats.residentBytes || 0) / 1048576)}MB of ` +
|
|
507
|
+
`${Math.round((stats.budgetBytes || 0) / 1048576)}MB allowed) ` +
|
|
503
508
|
`pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
|
|
504
509
|
`spills=${stats.spills} revivals=${stats.revivals}` +
|
|
505
510
|
(stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Read usrsctp's live association state, without a rebuild.
|
|
3
|
+
*
|
|
4
|
+
* Roadmap item 11. `node_datachannel.node` ships unstripped — it carries
|
|
5
|
+
* usrsctp's own local symbols, including `system_base_info` and
|
|
6
|
+
* `usrsctp_getsockopt` — so the association's real state (peer receive
|
|
7
|
+
* window, pending data, retransmission timeout, congestion window) can be
|
|
8
|
+
* read out of THIS live process with a short gdb attach. No source rebuild,
|
|
9
|
+
* no SCTP_DEBUG image, no waiting for a packet capture to be read by eye.
|
|
10
|
+
*
|
|
11
|
+
* The walk (hash the association table, call `usrsctp_getsockopt` with
|
|
12
|
+
* `SCTP_STATUS` through the running process) and the healthy baseline it was
|
|
13
|
+
* checked against are in
|
|
14
|
+
* `research/session-2026-08-27-28-freeze-onset-and-sessions.md`, section 1.
|
|
15
|
+
* The script itself is bundled at {@link SCTPSTATE_SCRIPT_PATH} rather than
|
|
16
|
+
* hand-placed on a host, so it ships with every release instead of surviving
|
|
17
|
+
* only as long as someone remembers to copy it back after a container is
|
|
18
|
+
* recreated.
|
|
19
|
+
*
|
|
20
|
+
* `gdb` attaching with ptrace pauses every thread of the process for the
|
|
21
|
+
* duration of the read — one `getsockopt` call, measured at a fraction of a
|
|
22
|
+
* second in the manual procedure this automates.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
import { shouldStartCapture, WITNESS_COOLDOWN_MS } from "./packet-witness.js";
|
|
30
|
+
|
|
31
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
|
|
33
|
+
/** The bundled gdb script that performs the usrsctp state walk. */
|
|
34
|
+
export const SCTPSTATE_SCRIPT_PATH = path.join(HERE, "..", "assets", "diagnostics", "sctpstate.gdb");
|
|
35
|
+
|
|
36
|
+
/** How long gdb may run before it is killed. Generous: this is a rare, one-shot read. */
|
|
37
|
+
export const GDB_TIMEOUT_MS = 15_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create the reader.
|
|
41
|
+
*
|
|
42
|
+
* Single-flight and cooldown-gated the same way the packet witness is
|
|
43
|
+
* ({@link shouldStartCapture}) — a wedge that stays certain for minutes must
|
|
44
|
+
* not spawn a fresh gdb attach on every tick, and two readings ten seconds
|
|
45
|
+
* apart tell the same story a hundred readings would.
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} options
|
|
48
|
+
* @param {(message: string) => void} options.log
|
|
49
|
+
* @param {typeof spawn} [options.spawnProcess] - Seam for tests.
|
|
50
|
+
* @param {number} [options.pid] - Defaults to this process's own pid.
|
|
51
|
+
* @param {string} [options.scriptPath]
|
|
52
|
+
* @param {number} [options.cooldownMs]
|
|
53
|
+
* @returns {{ maybeRead: (reasonText: string) => boolean }}
|
|
54
|
+
*/
|
|
55
|
+
export function createUsrsctpStateReader({
|
|
56
|
+
log,
|
|
57
|
+
spawnProcess = spawn,
|
|
58
|
+
pid = process.pid,
|
|
59
|
+
scriptPath = SCTPSTATE_SCRIPT_PATH,
|
|
60
|
+
cooldownMs = WITNESS_COOLDOWN_MS
|
|
61
|
+
}) {
|
|
62
|
+
/** @type {{ running: boolean, lastStartedAt: number }} */
|
|
63
|
+
const state = { running: false, lastStartedAt: 0 };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read the association state now, if the gating rules allow it.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} reasonText - What declared the wedge, for the log line.
|
|
69
|
+
* @returns {boolean} True when a read actually started.
|
|
70
|
+
*/
|
|
71
|
+
const maybeRead = (reasonText) => {
|
|
72
|
+
if (!shouldStartCapture({ ...state, cooldownMs })) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
state.running = true;
|
|
76
|
+
state.lastStartedAt = Date.now();
|
|
77
|
+
const startedAt = state.lastStartedAt;
|
|
78
|
+
void (async () => {
|
|
79
|
+
let out = "";
|
|
80
|
+
let err = "";
|
|
81
|
+
try {
|
|
82
|
+
await new Promise((resolve) => {
|
|
83
|
+
let settled = false;
|
|
84
|
+
let child;
|
|
85
|
+
const finish = () => {
|
|
86
|
+
if (settled) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
settled = true;
|
|
90
|
+
clearTimeout(killer);
|
|
91
|
+
resolve();
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
child = spawnProcess(
|
|
95
|
+
"gdb",
|
|
96
|
+
["-q", "-batch", "-p", String(pid), "-x", scriptPath],
|
|
97
|
+
{ stdio: ["ignore", "pipe", "pipe"] }
|
|
98
|
+
);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
err = `could not start gdb: ${error?.message ?? error}`;
|
|
101
|
+
resolve();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
child.stdout?.on("data", (chunk) => {
|
|
105
|
+
out += chunk.toString();
|
|
106
|
+
});
|
|
107
|
+
child.stderr?.on("data", (chunk) => {
|
|
108
|
+
err += chunk.toString();
|
|
109
|
+
});
|
|
110
|
+
child.on("error", (error) => {
|
|
111
|
+
err += `${err ? " " : ""}gdb error: ${error?.message ?? error}`;
|
|
112
|
+
finish();
|
|
113
|
+
});
|
|
114
|
+
child.on("close", finish);
|
|
115
|
+
const killer = setTimeout(() => {
|
|
116
|
+
try {
|
|
117
|
+
child.kill("SIGKILL");
|
|
118
|
+
} catch {
|
|
119
|
+
// already gone
|
|
120
|
+
}
|
|
121
|
+
}, GDB_TIMEOUT_MS);
|
|
122
|
+
if (typeof killer.unref === "function") {
|
|
123
|
+
killer.unref();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
} finally {
|
|
127
|
+
const lines = out.trim().length > 0 ? out.trim().split("\n") : [];
|
|
128
|
+
if (lines.length > 0) {
|
|
129
|
+
log(`usrsctp state (${reasonText}): ${lines.join(" | ")}`);
|
|
130
|
+
} else {
|
|
131
|
+
log(`usrsctp state (${reasonText}): no reading — ${err.trim() || "gdb produced no output"}`);
|
|
132
|
+
}
|
|
133
|
+
state.running = false;
|
|
134
|
+
// Same spacing rule as the packet witness: honour the cooldown even
|
|
135
|
+
// when the read ended quickly.
|
|
136
|
+
const earliestNext = startedAt + cooldownMs;
|
|
137
|
+
if (state.lastStartedAt < earliestNext) {
|
|
138
|
+
state.lastStartedAt = earliestNext;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
return true;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { maybeRead };
|
|
146
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { describeMemory } from "../services/memory-report.js";
|
|
5
|
+
import {
|
|
6
|
+
budgetForNewStore,
|
|
7
|
+
totalStoreBudgetBytes
|
|
8
|
+
} from "../services/piece-store/shared-piece-store.js";
|
|
9
|
+
|
|
10
|
+
const MEGABYTE = 1024 * 1024;
|
|
11
|
+
const GIGABYTE = 1024 * MEGABYTE;
|
|
12
|
+
|
|
13
|
+
test("the whole of the torrent stores is bounded, not each one of them", () => {
|
|
14
|
+
// The failure this replaces: the budget was per torrent, so two torrents took
|
|
15
|
+
// two of it. Whatever the machine has, the total is one budget.
|
|
16
|
+
const available = 8 * GIGABYTE;
|
|
17
|
+
const alone = budgetForNewStore(available, 1);
|
|
18
|
+
const withThree = budgetForNewStore(available, 3);
|
|
19
|
+
assert.equal(alone, totalStoreBudgetBytes(available));
|
|
20
|
+
assert.ok(withThree < alone, "a third store must not be given a first store's share");
|
|
21
|
+
assert.ok(withThree * 3 <= totalStoreBudgetBytes(available) + 3);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("the budget is a share of what the machine can give, capped", () => {
|
|
25
|
+
// A quarter of two gigabytes is under the ceiling and is what is taken.
|
|
26
|
+
assert.equal(totalStoreBudgetBytes(2 * GIGABYTE), 512 * MEGABYTE);
|
|
27
|
+
// A quarter of one gigabyte is 256 MB — below the ceiling, so not capped.
|
|
28
|
+
assert.equal(totalStoreBudgetBytes(GIGABYTE), 256 * MEGABYTE);
|
|
29
|
+
// And it never exceeds the ceiling however much is free.
|
|
30
|
+
assert.equal(totalStoreBudgetBytes(64 * GIGABYTE), 512 * MEGABYTE);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a machine with almost nothing left still gets a workable floor", () => {
|
|
34
|
+
// Refusing to serve is worse than exceeding the share, and the memory line
|
|
35
|
+
// says plainly what is held either way.
|
|
36
|
+
const tiny = budgetForNewStore(32 * MEGABYTE, 4);
|
|
37
|
+
assert.equal(tiny, 64 * MEGABYTE);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the memory line says bytes, and names what it could not measure", () => {
|
|
41
|
+
const usage = {
|
|
42
|
+
rss: 2422628 * 1024,
|
|
43
|
+
heapUsed: 180 * MEGABYTE,
|
|
44
|
+
heapTotal: 220 * MEGABYTE,
|
|
45
|
+
external: 900 * MEGABYTE,
|
|
46
|
+
arrayBuffers: 850 * MEGABYTE
|
|
47
|
+
};
|
|
48
|
+
const measured = describeMemory({
|
|
49
|
+
process: usage,
|
|
50
|
+
availableBytes: 1900 * MEGABYTE,
|
|
51
|
+
availableMeasured: true,
|
|
52
|
+
stores: [
|
|
53
|
+
{ name: "a film", residentBytes: 504 * MEGABYTE, budgetBytes: 512 * MEGABYTE }
|
|
54
|
+
]
|
|
55
|
+
});
|
|
56
|
+
// The figures the kernel's own kill line quoted, so the two can be compared.
|
|
57
|
+
assert.match(measured, /rss=2366MB/);
|
|
58
|
+
assert.match(measured, /1 torrent store\(s\) holding 504MB of 512MB allowed/);
|
|
59
|
+
assert.match(measured, /machine has 1900MB available$/);
|
|
60
|
+
|
|
61
|
+
const estimated = describeMemory({
|
|
62
|
+
process: usage,
|
|
63
|
+
availableBytes: 1900 * MEGABYTE,
|
|
64
|
+
availableMeasured: false,
|
|
65
|
+
stores: []
|
|
66
|
+
});
|
|
67
|
+
assert.match(estimated, /no torrent stores/);
|
|
68
|
+
assert.match(estimated, /estimated — \/proc\/meminfo could not be read/);
|
|
69
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { probeWedgeIsCertain, readProbeState, PROBE_INTERVAL_MS } from "../services/delivery-probe.js";
|
|
5
|
+
|
|
6
|
+
test("a seen-counter bounded lag is not a wedge, however long it lasts", () => {
|
|
7
|
+
// Session 4dcac61b, field log 2026-08-28: gap held at 6-7 probes for 95+
|
|
8
|
+
// seconds on a backgrounded tab, but `seen` kept climbing right along with
|
|
9
|
+
// `sent` — this connection's own history says gaps up to ~3.5 s (7 probes
|
|
10
|
+
// at 500 ms) are ordinary, so the same 3.5 s stuck must not read as certain.
|
|
11
|
+
const verdict = probeWedgeIsCertain({
|
|
12
|
+
stuckForMs: 3400,
|
|
13
|
+
longestHealthySeenGapMs: 3500
|
|
14
|
+
});
|
|
15
|
+
assert.equal(verdict.certain, false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("a seen-counter frozen past this connection's own worst legitimate gap is a wedge", () => {
|
|
19
|
+
// Session d85ae4f5, the same field log: `seen` frozen at one value for over
|
|
20
|
+
// a minute while `sent` climbed unbounded — this is the shape the detector
|
|
21
|
+
// exists to catch.
|
|
22
|
+
const verdict = probeWedgeIsCertain({
|
|
23
|
+
stuckForMs: 90_000,
|
|
24
|
+
longestHealthySeenGapMs: 3500
|
|
25
|
+
});
|
|
26
|
+
assert.equal(verdict.certain, true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("with no healthy history yet, one probe interval is still required", () => {
|
|
30
|
+
const verdict = probeWedgeIsCertain({
|
|
31
|
+
stuckForMs: PROBE_INTERVAL_MS - 1,
|
|
32
|
+
longestHealthySeenGapMs: 0
|
|
33
|
+
});
|
|
34
|
+
assert.equal(verdict.certain, false);
|
|
35
|
+
assert.equal(verdict.needMs, PROBE_INTERVAL_MS);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("readProbeState calls association-stopped only when the unreliable channel agrees", () => {
|
|
39
|
+
// Ordered channels behind, but the unordered/no-retransmit one current:
|
|
40
|
+
// head-of-line blocking in one stream, not the association.
|
|
41
|
+
const streamStuck = readProbeState({
|
|
42
|
+
seq: 100,
|
|
43
|
+
seen: { proxy: 90, "proxy-control": 90, "proxy-fast": 99 },
|
|
44
|
+
labels: ["proxy", "proxy-control", "proxy-fast"],
|
|
45
|
+
echoes: 5,
|
|
46
|
+
echoAgeMs: 100,
|
|
47
|
+
allowed: { proxy: 2, "proxy-control": 2, "proxy-fast": 2 }
|
|
48
|
+
});
|
|
49
|
+
assert.equal(streamStuck.verdict, "stream-stuck");
|
|
50
|
+
|
|
51
|
+
const associationStopped = readProbeState({
|
|
52
|
+
seq: 100,
|
|
53
|
+
seen: { proxy: 90, "proxy-control": 90, "proxy-fast": 90 },
|
|
54
|
+
labels: ["proxy", "proxy-control", "proxy-fast"],
|
|
55
|
+
echoes: 5,
|
|
56
|
+
echoAgeMs: 100,
|
|
57
|
+
allowed: { proxy: 2, "proxy-control": 2, "proxy-fast": 2 }
|
|
58
|
+
});
|
|
59
|
+
assert.equal(associationStopped.verdict, "association-stopped");
|
|
60
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The usrsctp state reader's gating and command construction.
|
|
3
|
+
*
|
|
4
|
+
* Roadmap item 11: reads usrsctp's live association state via gdb the moment
|
|
5
|
+
* a wedge is declared. Everything here is the part that decides WHEN and WITH
|
|
6
|
+
* WHAT ARGUMENTS — the spawning itself is thin glue around these, the same
|
|
7
|
+
* shape as the packet witness (test/packet-witness.test.js).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
|
|
14
|
+
import { createUsrsctpStateReader, SCTPSTATE_SCRIPT_PATH } from "../services/usrsctp-state.js";
|
|
15
|
+
|
|
16
|
+
class FakeChild extends EventEmitter {
|
|
17
|
+
constructor(command, args) {
|
|
18
|
+
super();
|
|
19
|
+
this.command = command;
|
|
20
|
+
this.args = args;
|
|
21
|
+
this.stdout = new EventEmitter();
|
|
22
|
+
this.stderr = new EventEmitter();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
kill() {
|
|
26
|
+
// Not exercised by these tests — every fake run finishes on its own.
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} output - What the fake gdb writes to stdout before closing.
|
|
33
|
+
* @returns {{ spawnProcess: Function, calls: Array<{ command: string, args: string[] }> }}
|
|
34
|
+
*/
|
|
35
|
+
function makeSpawn(output) {
|
|
36
|
+
const calls = [];
|
|
37
|
+
const spawnProcess = (command, args) => {
|
|
38
|
+
calls.push({ command, args });
|
|
39
|
+
const child = new FakeChild(command, args);
|
|
40
|
+
setImmediate(() => {
|
|
41
|
+
if (output) {
|
|
42
|
+
child.stdout.emit("data", Buffer.from(output));
|
|
43
|
+
}
|
|
44
|
+
child.emit("close", 0);
|
|
45
|
+
});
|
|
46
|
+
return child;
|
|
47
|
+
};
|
|
48
|
+
return { spawnProcess, calls };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Wait until `check` holds, rather than for a chosen interval. */
|
|
52
|
+
async function waitFor(check) {
|
|
53
|
+
const deadline = Date.now() + 5_000;
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (check()) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (Date.now() > deadline) {
|
|
59
|
+
throw new Error("timed out waiting for the reading to be logged");
|
|
60
|
+
}
|
|
61
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("gdb is invoked attached to this process with the bundled script", async () => {
|
|
66
|
+
const { spawnProcess, calls } = makeSpawn("state=8 rwnd=95890\n");
|
|
67
|
+
const lines = [];
|
|
68
|
+
const reader = createUsrsctpStateReader({
|
|
69
|
+
log: (message) => lines.push(message),
|
|
70
|
+
spawnProcess,
|
|
71
|
+
pid: 4242
|
|
72
|
+
});
|
|
73
|
+
const started = reader.maybeRead("test wedge");
|
|
74
|
+
assert.equal(started, true);
|
|
75
|
+
await waitFor(() => lines.length > 0);
|
|
76
|
+
assert.equal(calls.length, 1);
|
|
77
|
+
assert.deepEqual(calls[0].args, ["-q", "-batch", "-p", "4242", "-x", SCTPSTATE_SCRIPT_PATH]);
|
|
78
|
+
assert.match(lines[0], /state=8 rwnd=95890/);
|
|
79
|
+
assert.match(lines[0], /test wedge/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("no output is reported as no reading, not silence", async () => {
|
|
83
|
+
const { spawnProcess } = makeSpawn("");
|
|
84
|
+
const lines = [];
|
|
85
|
+
const reader = createUsrsctpStateReader({ log: (message) => lines.push(message), spawnProcess, pid: 1 });
|
|
86
|
+
reader.maybeRead("empty case");
|
|
87
|
+
await waitFor(() => lines.length > 0);
|
|
88
|
+
assert.match(lines[0], /no reading/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a second read is refused within the cooldown, like the packet witness", async () => {
|
|
92
|
+
const { spawnProcess, calls } = makeSpawn("state=8\n");
|
|
93
|
+
const lines = [];
|
|
94
|
+
const reader = createUsrsctpStateReader({
|
|
95
|
+
log: (message) => lines.push(message),
|
|
96
|
+
spawnProcess,
|
|
97
|
+
pid: 1,
|
|
98
|
+
cooldownMs: 60_000
|
|
99
|
+
});
|
|
100
|
+
assert.equal(reader.maybeRead("first"), true);
|
|
101
|
+
await waitFor(() => lines.length > 0);
|
|
102
|
+
assert.equal(reader.maybeRead("second, too soon"), false);
|
|
103
|
+
assert.equal(calls.length, 1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a missing gdb is reported, not thrown", async () => {
|
|
107
|
+
const lines = [];
|
|
108
|
+
const spawnProcess = () => {
|
|
109
|
+
throw new Error("spawn gdb ENOENT");
|
|
110
|
+
};
|
|
111
|
+
const reader = createUsrsctpStateReader({ log: (message) => lines.push(message), spawnProcess, pid: 1 });
|
|
112
|
+
reader.maybeRead("no gdb on this host");
|
|
113
|
+
await waitFor(() => lines.length > 0);
|
|
114
|
+
assert.match(lines[0], /could not start gdb/);
|
|
115
|
+
});
|