bdy 1.24.2 → 1.24.4-beta

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bdy",
3
3
  "preferGlobal": false,
4
- "version": "1.24.2",
4
+ "version": "1.24.4-beta",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "homepage": "https://buddy.works/docs/cli",
@@ -3,9 +3,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ // FIRST, and it has to stay first: it raises the ssh2 channel window, and ssh2 reads that constant
7
+ // once, when it is loaded. Any import above this line that reaches ssh2 would pin the window at
8
+ // ssh2's 2MB default and cap every upload through a tunnel. See sshChannelWindow.ts.
9
+ const sshChannelWindow_1 = require("./sshChannelWindow");
6
10
  const output_1 = __importDefault(require("./output"));
7
11
  const logger_1 = __importDefault(require("./logger"));
8
12
  const stream_1 = __importDefault(require("stream"));
13
+ if (!sshChannelWindow_1.sshChannelWindow.applied) {
14
+ // Not fatal: uploads through a tunnel just keep ssh2's 2MB-per-round-trip ceiling.
15
+ logger_1.default.debug(`SSH channel window unchanged - ${sshChannelWindow_1.sshChannelWindow.reason}`);
16
+ }
9
17
  output_1.default.muteNodeWarnings();
10
18
  stream_1.default.setDefaultHighWaterMark(false, 65536);
11
19
  process.title = 'bdy';
@@ -12,6 +12,7 @@ class Logger {
12
12
  logPath = null;
13
13
  log1Path = null;
14
14
  logStream = null;
15
+ dest = null;
15
16
  p = null;
16
17
  ts = null;
17
18
  constructor() {
@@ -86,6 +87,24 @@ class Logger {
86
87
  if (this.logPath)
87
88
  this.applyLogRights(this.logPath);
88
89
  const pino = require('pino');
90
+ // Not stdout as a fallback: it is machine-read by `--format json` and the mcp command.
91
+ // `sync`, which pino.destination(fd) does not default to: an asynchronous destination
92
+ // buffers the line and writes it from the threadpool, so the tail of the log is exactly
93
+ // what an agent killed by its service manager - or by the crash being logged - loses,
94
+ // and every descriptor it still holds has to outlive whoever closes it.
95
+ this.dest =
96
+ this.logStream === null
97
+ ? { write: () => { } }
98
+ : pino.destination({
99
+ dest: this.logStream,
100
+ sync: true,
101
+ minLength: 0,
102
+ });
103
+ // A line that could not be written is not worth taking the process down for. Pino's own
104
+ // handler swallows EPIPE and re-emits the rest, and an EventEmitter re-emitting 'error'
105
+ // with nobody listening throws - so an unwritable log used to arrive as a crash.
106
+ // (the ternary above assigns an untyped pino value, which does not narrow the field)
107
+ this.dest.on?.('error', () => { });
89
108
  this.p = pino({
90
109
  level: this.debugOn ? 'debug' : 'info',
91
110
  timestamp: () => `, "time": "${new Date().toISOString()}"`,
@@ -95,11 +114,7 @@ class Logger {
95
114
  level: label.toUpperCase(),
96
115
  }),
97
116
  },
98
- },
99
- // Not stdout as a fallback: it is machine-read by `--format json` and the mcp command
100
- this.logStream === null
101
- ? { write: () => { } }
102
- : pino.destination(this.logStream));
117
+ }, this.dest);
103
118
  this.checkLogSize();
104
119
  this.ts = setInterval(() => {
105
120
  this.checkLogSize();
@@ -122,20 +137,36 @@ class Logger {
122
137
  stack() {
123
138
  this.getPino().info(new Error().stack);
124
139
  }
140
+ // The descriptor belongs to the destination from the moment pino is handed one, so the close
141
+ // goes through the stream rather than through the number: closeSync on the number leaves a
142
+ // live stream holding a descriptor the kernel is free to hand to the next open - an EBADF at
143
+ // best, a log line in an unrelated file at worst - and pino, for a destination it opened
144
+ // asynchronously, flushes it again on exit. Anything still queued goes out first.
145
+ closeDestination() {
146
+ const dest = this.dest;
147
+ this.dest = null;
148
+ this.logStream = null;
149
+ if (!dest)
150
+ return;
151
+ try {
152
+ dest.flushSync?.();
153
+ }
154
+ catch {
155
+ // a destination that never opened, or one with nothing left to write
156
+ }
157
+ try {
158
+ dest.destroy?.();
159
+ }
160
+ catch {
161
+ // do nothing
162
+ }
163
+ }
125
164
  changeRootPath(path) {
126
165
  if (this.ts) {
127
166
  clearInterval(this.ts);
128
167
  this.ts = null;
129
168
  }
130
- if (this.logStream) {
131
- try {
132
- fs_1.default.closeSync(this.logStream);
133
- }
134
- catch {
135
- // do nothing
136
- }
137
- this.logStream = null;
138
- }
169
+ this.closeDestination();
139
170
  this.p = null;
140
171
  this.rootPath = path;
141
172
  }
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sshChannelWindow = void 0;
4
+ // The SSH channel window, raised before anything in this process loads ssh2.
5
+ //
6
+ // Throughput over one SSH channel is window/RTT, and ssh2 ships a 2 MB window (lib/Channel.js) that is
7
+ // a module constant, not an option. The agent is the RECEIVING end of an upload - data flows worker ->
8
+ // agent - so it is this side's window that decides how fast anything can be uploaded through a tunnel.
9
+ //
10
+ // Measured with a real OpenSSH sftp upload through a tunnel at 60 ms RTT: 11.7 MB/s with 2 MB here,
11
+ // 18.2 MB/s with 8 MB, against 19.0 MB/s for the same client and server with no tunnel in between - so
12
+ // 8 MB removes almost all of the tunnel's penalty. Raising it on the WORKER alone changes nothing at
13
+ // all (measured: still 11.7 MB/s), which is why this half exists. Past 8 MB the inner SSH becomes the
14
+ // ceiling and there is nothing left to win: 32 MB measured 18.7 MB/s.
15
+ //
16
+ // Memory: a receive window only becomes resident when the consumer is slower than the sender, bounded
17
+ // by the window per busy channel. On an agent that is a handful of channels on one machine, so there
18
+ // is no knob here - unlike the worker, which multiplies it by every concurrent transfer on an edge and
19
+ // reads SSH_CHANNEL_WINDOW for exactly that reason.
20
+ const SSH_CHANNEL_WINDOW = 8 * 1024 * 1024;
21
+ // Why a mutation rather than a patched dependency: client.js and server.js read these two constants
22
+ // ONCE, by destructuring lib/Channel.js at module load, so replacing them before ssh2 is first
23
+ // required is enough. And ssh2 is a bundleDependency published straight out of node_modules with
24
+ // `npm ci --ignore-scripts`, so a patch step would be one forgotten command away from silently
25
+ // shipping the 2 MB default - whereas this is source, and it travels with the build.
26
+ //
27
+ // Imported FIRST in src/index.ts, which is the only entry point (bin/cli.js requires it) and already
28
+ // the home of process-wide setup like stream.setDefaultHighWaterMark. Requiring only Channel.js here
29
+ // does not pull in client.js or server.js, so the constants are still unread at this point.
30
+ // What happened, for src/index.ts to log once the logger exists. This module deliberately imports
31
+ // NOTHING - logger pulls in ./utils, and the first import of the process reaching that far could drag
32
+ // ssh2 in with it and silence the very thing being set here.
33
+ exports.sshChannelWindow = { bytes: SSH_CHANNEL_WINDOW, applied: false, reason: '' };
34
+ const applyChannelWindow = () => {
35
+ try {
36
+ // If ssh2 is already loaded the constants have been read and this can no longer take effect. It
37
+ // means a new module started importing ssh2 above this line - the symptom would otherwise be
38
+ // nothing but slower uploads.
39
+ if (Object.keys(require.cache).some((f) => f.includes(`ssh2${'/'}lib${'/'}client.js`))) {
40
+ exports.sshChannelWindow.reason = 'ssh2 was already loaded, so its constants had been read';
41
+ return;
42
+ }
43
+ const channel = require('ssh2/lib/Channel.js');
44
+ if (typeof channel.MAX_WINDOW !== 'number' || typeof channel.WINDOW_THRESHOLD !== 'number') {
45
+ exports.sshChannelWindow.reason = 'ssh2 no longer exposes MAX_WINDOW/WINDOW_THRESHOLD';
46
+ return;
47
+ }
48
+ if (SSH_CHANNEL_WINDOW < channel.MAX_WINDOW) {
49
+ exports.sshChannelWindow.reason = `ssh2 already defaults to ${channel.MAX_WINDOW} bytes`;
50
+ return;
51
+ }
52
+ channel.MAX_WINDOW = SSH_CHANNEL_WINDOW;
53
+ // ssh2 tops the window up once half of it is spent. Left at its own 1 MB it would top up only in
54
+ // the last eighth of an 8 MB window, which is exactly when a fast sender runs dry.
55
+ channel.WINDOW_THRESHOLD = Math.floor(SSH_CHANNEL_WINDOW / 2);
56
+ exports.sshChannelWindow.applied = true;
57
+ }
58
+ catch (err) {
59
+ // ssh2 moved, or is not resolvable from here: keep the tunnel working on ssh2's defaults rather
60
+ // than failing to start over a throughput knob. Recorded rather than swallowed - the symptom is
61
+ // otherwise nothing but slower uploads, which is how this cost went unnoticed in the first place.
62
+ exports.sshChannelWindow.reason = `${err?.message || err}`;
63
+ }
64
+ };
65
+ applyChannelWindow();
66
+ exports.default = SSH_CHANNEL_WINDOW;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bdy",
3
3
  "preferGlobal": false,
4
- "version": "1.24.2",
4
+ "version": "1.24.4-beta",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "homepage": "https://buddy.works/docs/cli",