seshmux 0.1.3 → 0.1.5
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-build-manifest.json +1 -1
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/app/page-b9c3810608a2c3d4.js +1 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/seshmux-server.js +160 -124
- package/bin/seshmux.js +232 -4
- package/daemon/ensure.js +55 -0
- package/daemon/holder.js +248 -0
- package/daemon/index.js +4 -1
- package/daemon/pty-manager.js +341 -29
- package/package.json +2 -2
- package/.next/standalone/.next/static/chunks/app/page-7104066577bb8e5f.js +0 -1
- /package/.next/standalone/.next/static/{kPJ9M1iZAJK4qUIbW1h9b → INANHMG7kWM6Af6v_Vj9d}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{kPJ9M1iZAJK4qUIbW1h9b → INANHMG7kWM6Af6v_Vj9d}/_ssgManifest.js +0 -0
package/daemon/ensure.js
CHANGED
|
@@ -109,6 +109,59 @@ function tryHello(sockPath, timeoutMs = HELLO_TIMEOUT_MS) {
|
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
/**
|
|
113
|
+
* PURE predicate (unit-tested): may we restart the daemon without ending a live agent session?
|
|
114
|
+
* tmux-tier PTYs rehydrate from `tmux ls` in the fresh daemon and survive; PLAIN-tier PTYs
|
|
115
|
+
* (tmuxName null — machine without tmux) die with it. So: safe only when every LIVE pty is
|
|
116
|
+
* tmux-backed, or there are none. Dead entries can't be killed twice, so they don't block.
|
|
117
|
+
* @param {{tmuxName: string|null, alive?: boolean}[]} ptys — the daemon's `list` result
|
|
118
|
+
* @returns {{safe: boolean, plainCount: number}}
|
|
119
|
+
*/
|
|
120
|
+
function canSafelyRestartDaemon(ptys) {
|
|
121
|
+
const live = (ptys || []).filter((p) => p && p.alive !== false);
|
|
122
|
+
const plainCount = live.filter((p) => !p.tmuxName).length;
|
|
123
|
+
return { safe: plainCount === 0, plainCount };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One dial: hello + list. Resolves { version, ptys } or null if the daemon isn't reachable.
|
|
128
|
+
* Used by the supervisor's auto-upgrade decision (bin/seshmux.js) — never throws.
|
|
129
|
+
*/
|
|
130
|
+
function daemonInfo(sockPath, timeoutMs = HELLO_TIMEOUT_MS) {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
let settled = false;
|
|
133
|
+
let version = null;
|
|
134
|
+
const done = (v) => {
|
|
135
|
+
if (settled) return;
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
try {
|
|
139
|
+
sock.destroy();
|
|
140
|
+
} catch {
|
|
141
|
+
/* ignore */
|
|
142
|
+
}
|
|
143
|
+
resolve(v);
|
|
144
|
+
};
|
|
145
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
146
|
+
const decoder = createDecoder();
|
|
147
|
+
const sock = net.connect(sockPath);
|
|
148
|
+
sock.setEncoding('utf8');
|
|
149
|
+
sock.on('connect', () => {
|
|
150
|
+
sock.write(encode({ id: 1, method: 'hello' }));
|
|
151
|
+
sock.write(encode({ id: 2, method: 'list' }));
|
|
152
|
+
});
|
|
153
|
+
sock.on('data', (chunk) => {
|
|
154
|
+
for (const msg of decoder.push(chunk)) {
|
|
155
|
+
if (!msg || !msg.result) continue;
|
|
156
|
+
if (msg.id === 1) version = msg.result.version || null;
|
|
157
|
+
if (msg.id === 2) done({ version, ptys: msg.result.ptys || [] });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
sock.on('error', () => done(null));
|
|
161
|
+
sock.on('close', () => done(null));
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
112
165
|
const sleep = (ms) =>
|
|
113
166
|
new Promise((r) => {
|
|
114
167
|
setTimeout(r, ms);
|
|
@@ -218,6 +271,8 @@ async function ensureDaemon(opts = {}) {
|
|
|
218
271
|
|
|
219
272
|
module.exports = {
|
|
220
273
|
classify,
|
|
274
|
+
canSafelyRestartDaemon,
|
|
275
|
+
daemonInfo,
|
|
221
276
|
pidAlive,
|
|
222
277
|
tryHello,
|
|
223
278
|
ensureDaemon,
|
package/daemon/holder.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* seshmux PTY holder — a tiny detached process that OWNS one PTY.
|
|
4
|
+
*
|
|
5
|
+
* Why: the daemon used to `pty.spawn` directly, so it held the master fd. Kill
|
|
6
|
+
* the daemon (crash, restart, upgrade) and the fd closed, the child got SIGHUP,
|
|
7
|
+
* and the user's agent died. Only the tmux tier survived, because tmux owned
|
|
8
|
+
* the process. The holder is the tmux tier for machines without tmux: it sits
|
|
9
|
+
* between the daemon and the PTY, is spawned detached+setsid+unref'd, ignores
|
|
10
|
+
* SIGHUP, and keeps buffering output while no daemon is attached.
|
|
11
|
+
*
|
|
12
|
+
* Plain CJS, zero build step, node-pty is the only dep (same rules as daemon/).
|
|
13
|
+
*
|
|
14
|
+
* Wire (NDJSON, same framing helpers as the daemon protocol — this is the
|
|
15
|
+
* holder<->daemon link, NOT the frozen daemon<->server protocol):
|
|
16
|
+
* holder -> daemon: {event:'ready', ptyId} first frame to the accepted client
|
|
17
|
+
* {event:'busy'} a client is already attached; go away
|
|
18
|
+
* {event:'data', data} replay (one frame) then live output
|
|
19
|
+
* {event:'exit', code} the PTY exited
|
|
20
|
+
* daemon -> holder: {method:'write', data}
|
|
21
|
+
* {method:'resize', cols, rows}
|
|
22
|
+
* {method:'kill'}
|
|
23
|
+
*
|
|
24
|
+
* Exactly ONE client at a time (that's the no-double-attach guarantee). A
|
|
25
|
+
* client disconnect never touches the PTY.
|
|
26
|
+
*
|
|
27
|
+
* Argv: node holder.js '<json spec>' where spec =
|
|
28
|
+
* { holderDir, ptyId, sock, cwd, args, cols, rows, env }
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const net = require('node:net');
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
const pty = require('@homebridge/node-pty-prebuilt-multiarch');
|
|
35
|
+
const {
|
|
36
|
+
RING_BUFFER_LINES,
|
|
37
|
+
RING_BUFFER_BYTES,
|
|
38
|
+
encode,
|
|
39
|
+
createDecoder,
|
|
40
|
+
} = require('./protocol');
|
|
41
|
+
|
|
42
|
+
// After the PTY exits we keep the socket up so a daemon that reconnects can
|
|
43
|
+
// still learn the exit code. Long grace when nobody knew; short when a live
|
|
44
|
+
// client already got the exit frame (or explicitly asked for the kill), so we
|
|
45
|
+
// don't leave a node process loitering for a minute per closed session.
|
|
46
|
+
const EXIT_GRACE_MS = 60 * 1000;
|
|
47
|
+
const EXIT_GRACE_KNOWN_MS = 5 * 1000;
|
|
48
|
+
|
|
49
|
+
// The daemon's death must not be ours. (detached+stdio:'ignore' covers the fd
|
|
50
|
+
// side; this covers the signal side.)
|
|
51
|
+
process.on('SIGHUP', () => {});
|
|
52
|
+
|
|
53
|
+
const spec = JSON.parse(process.argv[2] || '{}');
|
|
54
|
+
const { holderDir, ptyId, sock: sockPath, cwd, args, cols, rows, env } = spec;
|
|
55
|
+
const jsonPath = path.join(holderDir, ptyId + '.json');
|
|
56
|
+
|
|
57
|
+
const proc = pty.spawn(args[0], args.slice(1), {
|
|
58
|
+
name: 'xterm-256color',
|
|
59
|
+
cols: cols || 80,
|
|
60
|
+
rows: rows || 24,
|
|
61
|
+
cwd,
|
|
62
|
+
env: { ...process.env, ...(env || {}) },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Same ring semantics (and same caps) as the daemon's — bytes are replayed
|
|
66
|
+
// verbatim, never re-lined, so escape sequences survive.
|
|
67
|
+
const ring = [];
|
|
68
|
+
let ringLines = 0;
|
|
69
|
+
let ringBytes = 0;
|
|
70
|
+
|
|
71
|
+
function countNewlines(str) {
|
|
72
|
+
let n = 0;
|
|
73
|
+
for (let i = 0; i < str.length; i++) if (str.charCodeAt(i) === 10) n++;
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function appendRing(chunk) {
|
|
78
|
+
ring.push(chunk);
|
|
79
|
+
ringLines += countNewlines(chunk);
|
|
80
|
+
ringBytes += chunk.length;
|
|
81
|
+
while (ring.length > 1 && (ringLines > RING_BUFFER_LINES || ringBytes > RING_BUFFER_BYTES)) {
|
|
82
|
+
const dropped = ring.shift();
|
|
83
|
+
ringLines -= countNewlines(dropped);
|
|
84
|
+
ringBytes -= dropped.length;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @type {net.Socket|null} the single attached client (the daemon) */
|
|
89
|
+
let client = null;
|
|
90
|
+
/** @type {{code:number}|null} */
|
|
91
|
+
let exited = null;
|
|
92
|
+
let exitKnown = false; // a client saw the exit, or asked for the kill
|
|
93
|
+
let cleaning = false;
|
|
94
|
+
let cleanupTimer = null;
|
|
95
|
+
|
|
96
|
+
/** (Re)arm the post-exit grace. Shortened once a client has learned the exit —
|
|
97
|
+
* a short-lived process can exit before the daemon even finishes connecting,
|
|
98
|
+
* so the grace is re-armed on connect, not decided once at exit time. */
|
|
99
|
+
function scheduleCleanup(ms) {
|
|
100
|
+
if (cleanupTimer) clearTimeout(cleanupTimer);
|
|
101
|
+
cleanupTimer = setTimeout(cleanup, ms);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function send(msg) {
|
|
105
|
+
if (client && !client.destroyed) client.write(encode(msg));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
proc.onData((data) => {
|
|
109
|
+
appendRing(data);
|
|
110
|
+
send({ event: 'data', data });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
proc.onExit(({ exitCode }) => {
|
|
114
|
+
exited = { code: exitCode };
|
|
115
|
+
if (client && !client.destroyed) exitKnown = true;
|
|
116
|
+
send({ event: 'exit', code: exitCode });
|
|
117
|
+
scheduleCleanup(exitKnown ? EXIT_GRACE_KNOWN_MS : EXIT_GRACE_MS);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** Remove socket + json and go. Never leave orphan files behind. */
|
|
121
|
+
function cleanup() {
|
|
122
|
+
if (cleaning) return;
|
|
123
|
+
cleaning = true;
|
|
124
|
+
try {
|
|
125
|
+
server.close();
|
|
126
|
+
} catch {
|
|
127
|
+
// ignore
|
|
128
|
+
}
|
|
129
|
+
for (const p of [sockPath, jsonPath]) {
|
|
130
|
+
try {
|
|
131
|
+
fs.unlinkSync(p);
|
|
132
|
+
} catch {
|
|
133
|
+
// ignore
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function handle(msg) {
|
|
140
|
+
switch (msg && msg.method) {
|
|
141
|
+
case 'write':
|
|
142
|
+
try {
|
|
143
|
+
proc.write(msg.data);
|
|
144
|
+
} catch {
|
|
145
|
+
// pty already gone
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
case 'resize':
|
|
149
|
+
try {
|
|
150
|
+
proc.resize(msg.cols || cols || 80, msg.rows || rows || 24);
|
|
151
|
+
} catch {
|
|
152
|
+
// pty already gone
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
case 'kill':
|
|
156
|
+
exitKnown = true;
|
|
157
|
+
try {
|
|
158
|
+
proc.kill();
|
|
159
|
+
} catch {
|
|
160
|
+
// already dead
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
default:
|
|
164
|
+
// ignore unknown
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const server = net.createServer((s) => {
|
|
169
|
+
// Single-client rule: a second daemon can never attach the same holder.
|
|
170
|
+
if (client && !client.destroyed) {
|
|
171
|
+
s.write(encode({ event: 'busy' }));
|
|
172
|
+
s.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
client = s;
|
|
176
|
+
const decoder = createDecoder();
|
|
177
|
+
s.on('data', (chunk) => {
|
|
178
|
+
for (const m of decoder.push(chunk.toString('utf8'))) handle(m);
|
|
179
|
+
});
|
|
180
|
+
s.on('error', () => {
|
|
181
|
+
if (client === s) client = null;
|
|
182
|
+
});
|
|
183
|
+
s.on('close', () => {
|
|
184
|
+
if (client === s) client = null;
|
|
185
|
+
// The daemon that knew about the exit has gone; nothing left to tell.
|
|
186
|
+
if (exited && exitKnown) cleanup();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ready -> replay -> live, all in this tick: nothing can slip into the gap.
|
|
190
|
+
s.write(encode({ event: 'ready', ptyId }));
|
|
191
|
+
const replay = ring.join('');
|
|
192
|
+
if (replay) s.write(encode({ event: 'data', data: replay }));
|
|
193
|
+
if (exited) {
|
|
194
|
+
exitKnown = true;
|
|
195
|
+
s.write(encode({ event: 'exit', code: exited.code }));
|
|
196
|
+
scheduleCleanup(EXIT_GRACE_KNOWN_MS); // this client now knows; don't loiter
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
for (const sig of ['SIGTERM', 'SIGINT']) {
|
|
201
|
+
process.on(sig, () => {
|
|
202
|
+
try {
|
|
203
|
+
proc.kill();
|
|
204
|
+
} catch {
|
|
205
|
+
// ignore
|
|
206
|
+
}
|
|
207
|
+
cleanup();
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
fs.unlinkSync(sockPath);
|
|
213
|
+
} catch {
|
|
214
|
+
// no stale socket — fine
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
server.listen(sockPath, () => {
|
|
218
|
+
try {
|
|
219
|
+
fs.chmodSync(sockPath, 0o600);
|
|
220
|
+
} catch {
|
|
221
|
+
// best effort
|
|
222
|
+
}
|
|
223
|
+
// Written AFTER listen, so a json on disk implies a socket to dial.
|
|
224
|
+
fs.writeFileSync(
|
|
225
|
+
jsonPath,
|
|
226
|
+
JSON.stringify({
|
|
227
|
+
ptyId,
|
|
228
|
+
pid: process.pid,
|
|
229
|
+
sock: sockPath,
|
|
230
|
+
cwd,
|
|
231
|
+
args,
|
|
232
|
+
cols: cols || 80,
|
|
233
|
+
rows: rows || 24,
|
|
234
|
+
startedAt: Date.now(),
|
|
235
|
+
})
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
server.on('error', () => {
|
|
240
|
+
// Can't listen (path too long, dir gone): the PTY is unreachable, so don't
|
|
241
|
+
// strand it — kill it and exit rather than leaving an invisible child.
|
|
242
|
+
try {
|
|
243
|
+
proc.kill();
|
|
244
|
+
} catch {
|
|
245
|
+
// ignore
|
|
246
|
+
}
|
|
247
|
+
cleanup();
|
|
248
|
+
});
|
package/daemon/index.js
CHANGED
|
@@ -243,7 +243,10 @@ async function startDaemon(opts = {}) {
|
|
|
243
243
|
|
|
244
244
|
fs.writeFileSync(pidPath, String(process.pid));
|
|
245
245
|
|
|
246
|
-
//
|
|
246
|
+
// Adopt PTYs that outlived the previous daemon: holder tier first (ptyIds are
|
|
247
|
+
// preserved there, and they were already reserved in the PtyManager ctor),
|
|
248
|
+
// then tmux-tier sessions (no-op if tmux absent).
|
|
249
|
+
await ptyManager.rehydrateHolders();
|
|
247
250
|
await ptyManager.rehydrateTmux();
|
|
248
251
|
|
|
249
252
|
return { server, ptyManager, sockPath, pidPath, close };
|