@monotykamary/localterm-server 2.15.3 → 2.16.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/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +40 -8
- package/dist/constants.js.map +1 -1
- package/dist/git-dirty-coordinator.d.ts +16 -0
- package/dist/git-dirty-coordinator.d.ts.map +1 -0
- package/dist/git-dirty-coordinator.js +68 -0
- package/dist/git-dirty-coordinator.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +127 -465
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +1 -1
- package/dist/protocol.js.map +1 -1
- package/dist/schemas.d.ts +66 -26
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +44 -4
- package/dist/schemas.js.map +1 -1
- package/dist/session-manager.d.ts +109 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/session-manager.js +598 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/session.d.ts +5 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +29 -1
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +4 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/ws-socket.d.ts +8 -0
- package/dist/utils/ws-socket.d.ts.map +1 -0
- package/dist/utils/ws-socket.js +11 -0
- package/dist/utils/ws-socket.js.map +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,23 +14,21 @@ import { CaffeinateController } from "./caffeinate-controller.js";
|
|
|
14
14
|
import { CaffeinateManager } from "./caffeinate-manager.js";
|
|
15
15
|
import { CaffeinatePreferencesStore } from "./caffeinate-preferences-store.js";
|
|
16
16
|
import { CdpClient } from "./cdp/cdp-client.js";
|
|
17
|
-
import { AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RECONCILE_MIN_DOWNTIME_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_MAX_REF_LENGTH, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, MAX_AUTOMATIONS,
|
|
17
|
+
import { AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RECONCILE_MIN_DOWNTIME_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_MAX_REF_LENGTH, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, MAX_AUTOMATIONS, MS_PER_MINUTE, SERVER_STOP_GRACE_MS, SESSION_ID_QUERY_PARAM, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_CLOSE_CAPACITY_REACHED, WS_CLOSE_POLICY_VIOLATION, WS_HEARTBEAT_GRACE_MS, WS_HEARTBEAT_INTERVAL_MS, WS_HEARTBEAT_TIMEOUT_MS, WS_READY_STATE_OPEN, } from "./constants.js";
|
|
18
18
|
import { getDefaultShell } from "./default-shell.js";
|
|
19
19
|
import { shellPathForUserShell } from "./utils/shell-path.js";
|
|
20
20
|
import { ServerErrorException, serverError } from "./errors.js";
|
|
21
21
|
import { FolderWatchManager } from "./folder-watch-manager.js";
|
|
22
22
|
import { SessionEventManager } from "./session-event-manager.js";
|
|
23
23
|
import { WebhookTriggerManager } from "./webhook-trigger-manager.js";
|
|
24
|
-
import { getGitBranchInfo, getGitBranchPr, getGitDiff, getGitDiffFilePatch, getGitDiffFiles, getGitDiffSummary,
|
|
25
|
-
import { GitDiffWatcher, GIT_DIFF_WATCHER_EVENT_NAMES, } from "./git-diff-watcher.js";
|
|
24
|
+
import { getGitBranchInfo, getGitBranchPr, getGitDiff, getGitDiffFilePatch, getGitDiffFiles, getGitDiffSummary, } from "./git-diff.js";
|
|
26
25
|
import { HeartbeatStore } from "./heartbeat-store.js";
|
|
27
26
|
import { parseCronExpression } from "./cron-expression.js";
|
|
28
27
|
import { createGitWorktree, listGitWorktrees, removeGitWorktree } from "./git-worktrees.js";
|
|
29
28
|
import { clientToServerMessageSchema, createAutomationInputSchema, createWorktreeInputSchema, launchInputSchema, resetAutomationInputSchema, updateAutomationInputSchema, updateWorktreeConfigInputSchema, worktreeIncludeFileInputSchema, } from "./schemas.js";
|
|
30
|
-
import { Session } from "./session.js";
|
|
31
29
|
import { createNetworkPolicyMiddleware, isAllowedSourceIp, isLoopbackHost } from "./security.js";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
30
|
+
import { SessionManager } from "./session-manager.js";
|
|
31
|
+
import { getBufferedAmount } from "./utils/ws-socket.js";
|
|
34
32
|
import { resolveStaticAsset } from "./static-resolver.js";
|
|
35
33
|
import { resolveImageAsset } from "./utils/resolve-image-asset.js";
|
|
36
34
|
import { sweepStaleWorktrees } from "./utils/worktree-sweep.js";
|
|
@@ -41,12 +39,6 @@ import { computeNextAutomationRunAt } from "./utils/compute-next-automation-run-
|
|
|
41
39
|
import { isLocaltermTabUrl } from "./utils/is-localterm-tab-url.js";
|
|
42
40
|
import { normalizeTriggerInput } from "./utils/normalize-trigger.js";
|
|
43
41
|
import { enumerateMissedOccurrences } from "./utils/reconcile-downtime.js";
|
|
44
|
-
const getRawBufferedAmount = (raw) => {
|
|
45
|
-
if (!raw || typeof raw !== "object")
|
|
46
|
-
return 0;
|
|
47
|
-
const candidate = Reflect.get(raw, "bufferedAmount");
|
|
48
|
-
return typeof candidate === "number" ? candidate : 0;
|
|
49
|
-
};
|
|
50
42
|
const callRawMethod = (raw, method) => {
|
|
51
43
|
if (!raw || typeof raw !== "object")
|
|
52
44
|
return false;
|
|
@@ -90,7 +82,7 @@ const extractRemoteAddress = (raw) => {
|
|
|
90
82
|
const safeSend = (ws, payload) => {
|
|
91
83
|
if (ws.readyState !== WS_READY_STATE_OPEN)
|
|
92
84
|
return;
|
|
93
|
-
if (
|
|
85
|
+
if (getBufferedAmount(ws) > WS_BACKPRESSURE_THRESHOLD_BYTES) {
|
|
94
86
|
ws.close(WS_CLOSE_BACKPRESSURE, "backpressure");
|
|
95
87
|
return;
|
|
96
88
|
}
|
|
@@ -101,111 +93,6 @@ const safeSend = (ws, payload) => {
|
|
|
101
93
|
/* socket closed between readyState check and send */
|
|
102
94
|
}
|
|
103
95
|
};
|
|
104
|
-
// Output frames travel as raw UTF-8 bytes, not JSON. JSON.stringify/parse on
|
|
105
|
-
// terminal output is the dominant per-byte cost on the renderer main thread
|
|
106
|
-
// (traced: ~36% of main-thread busy in steady-state cmatrix is JSON.parse of
|
|
107
|
-
// {"type":"output","data":"..."}, scaling linearly with payload size because of
|
|
108
|
-
// per-character escape scanning on both sides). PTY output is already bytes; the
|
|
109
|
-
// server UTF-8-encodes the accumulated string batch once at flush and emits a
|
|
110
|
-
// single binary frame. The client gets event.data as an ArrayBuffer and hands
|
|
111
|
-
// it to OutputBatcher with no JSON.parse, no string roundtrip. Splits at
|
|
112
|
-
// MAX_OUTPUT_BYTES as a safety cap on single-frame size (unreachable in
|
|
113
|
-
// practice — OUTPUT_BATCH_FLUSH_BYTES=32KB flushes well below this).
|
|
114
|
-
const sendOutputBytes = (ws, bytes) => {
|
|
115
|
-
if (ws.readyState !== WS_READY_STATE_OPEN)
|
|
116
|
-
return;
|
|
117
|
-
if (getRawBufferedAmount(ws.raw) > WS_BACKPRESSURE_THRESHOLD_BYTES) {
|
|
118
|
-
ws.close(WS_CLOSE_BACKPRESSURE, "backpressure");
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
ws.send(bytes);
|
|
123
|
-
}
|
|
124
|
-
catch {
|
|
125
|
-
/* socket closed between readyState check and send */
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
// Stateless UTF-8 encode + chunked send of the batch string. Shared by the
|
|
129
|
-
// onOpen flush path (which additionally enforces per-session backpressure)
|
|
130
|
-
// and the onClose/onError teardown paths (which don't — the socket is already
|
|
131
|
-
// closing, so triggering a PTY pause would just stall the teardown).
|
|
132
|
-
const sendOutputBatchBytes = (ws, batch) => {
|
|
133
|
-
if (!batch)
|
|
134
|
-
return;
|
|
135
|
-
const bytes = Buffer.from(batch, "utf8");
|
|
136
|
-
if (bytes.byteLength <= MAX_OUTPUT_BYTES) {
|
|
137
|
-
sendOutputBytes(ws, bytes);
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
for (let offset = 0; offset < bytes.byteLength; offset += MAX_OUTPUT_BYTES) {
|
|
141
|
-
sendOutputBytes(ws, bytes.subarray(offset, offset + MAX_OUTPUT_BYTES));
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
// Git metadata is per-repo, not per-tab. Two tabs in the same cwd share one
|
|
146
|
-
// working tree, so a git-dirty signal from one tab — its shell's precmd OSC
|
|
147
|
-
// hook, or its fs watcher on .git — must refresh every tab in that cwd, not
|
|
148
|
-
// just the one whose shell produced the prompt. Without this, a git operation
|
|
149
|
-
// run inside one of two side-by-side tabs updates only that tab; the sibling
|
|
150
|
-
// stays stale until its own shell next renders a prompt (its precmd hook) or
|
|
151
|
-
// its fs watcher happens to surface the change. The summary is pathscoped to
|
|
152
|
-
// the cwd (`git diff` from a subdirectory lists only files under it), so the
|
|
153
|
-
// coordinator is keyed by cwd, not by repo — tabs in different subdirectories
|
|
154
|
-
// of the same repo get distinct summaries and never share.
|
|
155
|
-
//
|
|
156
|
-
// One coordinator per cwd also dedups the summary computation across concurrent
|
|
157
|
-
// signals from sibling tabs: their independent fs watchers and prompt hooks all
|
|
158
|
-
// funnel into a single in-flight pass (with one trailing pass after the burst
|
|
159
|
-
// settles), and the result is broadcast to every subscribed socket.
|
|
160
|
-
class GitDirtyCoordinator {
|
|
161
|
-
cwd;
|
|
162
|
-
inFlight = false;
|
|
163
|
-
pending = false;
|
|
164
|
-
subscribers = new Set();
|
|
165
|
-
constructor(cwd) {
|
|
166
|
-
this.cwd = cwd;
|
|
167
|
-
}
|
|
168
|
-
add(socket) {
|
|
169
|
-
this.subscribers.add(socket);
|
|
170
|
-
}
|
|
171
|
-
remove(socket) {
|
|
172
|
-
this.subscribers.delete(socket);
|
|
173
|
-
}
|
|
174
|
-
get isEmpty() {
|
|
175
|
-
return this.subscribers.size === 0;
|
|
176
|
-
}
|
|
177
|
-
signal() {
|
|
178
|
-
if (this.inFlight) {
|
|
179
|
-
this.pending = true;
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
this.inFlight = true;
|
|
183
|
-
void this.run();
|
|
184
|
-
}
|
|
185
|
-
run = async () => {
|
|
186
|
-
try {
|
|
187
|
-
// The working tree changed, so any cached full-diff pass for this cwd is
|
|
188
|
-
// stale — drop it before re-reading the summary so the viewer's next
|
|
189
|
-
// per-file fetch rebuilds against the new tree.
|
|
190
|
-
invalidateGitDiffCache(this.cwd);
|
|
191
|
-
const summary = await getGitDiffSummary(this.cwd);
|
|
192
|
-
const payload = { type: "git-diff-summary", summary };
|
|
193
|
-
for (const socket of this.subscribers) {
|
|
194
|
-
safeSend(socket, payload);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
/* transient git failure; the next signal retries */
|
|
199
|
-
}
|
|
200
|
-
finally {
|
|
201
|
-
this.inFlight = false;
|
|
202
|
-
if (this.pending) {
|
|
203
|
-
this.pending = false;
|
|
204
|
-
this.signal();
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
96
|
export const createServer = async (options = {}) => {
|
|
210
97
|
const port = options.port ?? DEFAULT_PORT;
|
|
211
98
|
const host = options.host ?? DEFAULT_HOST;
|
|
@@ -214,12 +101,37 @@ export const createServer = async (options = {}) => {
|
|
|
214
101
|
if (!isLoopbackBind) {
|
|
215
102
|
console.warn(`⚠ non-loopback bind (${host}): any client on the private network can open an unauthenticated shell`);
|
|
216
103
|
}
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
104
|
+
// The session manager owns every live PTY for the daemon's lifetime. A PTY
|
|
105
|
+
// persists across client detach (closing a tab detaches instead of killing
|
|
106
|
+
// it) so the session picker can re-attach to it; it dies on shell exit, an
|
|
107
|
+
// explicit kill from the picker, or the dormant-idle sweep. Multiple clients
|
|
108
|
+
// may attach to one PTY and fan out output/resize to all of them. The hooks
|
|
109
|
+
// close over managers defined further below; they only fire at runtime
|
|
110
|
+
// (attach/detach/output/exit), so referencing the later consts here is safe.
|
|
111
|
+
const registry = new SessionManager({
|
|
112
|
+
sendControl: safeSend,
|
|
113
|
+
hooks: {
|
|
114
|
+
onOutputActivity: () => caffeinateManager.noteOutputActivity(),
|
|
115
|
+
onSessionActivity: () => caffeinateManager.pokeAuto(),
|
|
116
|
+
onSessionEvent: (event, cwd) => sessionEventManager.onSessionEvent(event, cwd),
|
|
117
|
+
onAutomationExit: (automationId, runId, exitCode) => {
|
|
118
|
+
automationStore.updateRun(automationId, runId, {
|
|
119
|
+
status: exitCode === 0 ? "completed" : "failed",
|
|
120
|
+
exitCode,
|
|
121
|
+
finishedAt: Date.now(),
|
|
122
|
+
});
|
|
123
|
+
broadcastAutomations();
|
|
124
|
+
closeRunTabIfRequested(automationId, runId);
|
|
125
|
+
folderWatchManager.notifyRunFinished(automationId);
|
|
126
|
+
sessionEventManager.notifyRunFinished(automationId);
|
|
127
|
+
},
|
|
128
|
+
onClientExit: (ws, exitCode) => {
|
|
129
|
+
const targetId = wsToTargetId.get(ws);
|
|
130
|
+
if (targetId && (exitCode === null || exitCode === 0))
|
|
131
|
+
void cdpClient?.closeTab(targetId);
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
223
135
|
const app = new Hono();
|
|
224
136
|
app.use("*", createNetworkPolicyMiddleware(host));
|
|
225
137
|
const { injectWebSocket, upgradeWebSocket, wss } = createNodeWebSocket({ app });
|
|
@@ -275,25 +187,11 @@ export const createServer = async (options = {}) => {
|
|
|
275
187
|
hasRecentOutput: (pids, withinMs) => registry.hasRecentOutput(pids, withinMs),
|
|
276
188
|
});
|
|
277
189
|
const clientSockets = new Set();
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
const gitDirtyCoordinatorsByCwd = new Map();
|
|
284
|
-
const coordinatorForCwd = (cwd) => {
|
|
285
|
-
const key = path.resolve(cwd);
|
|
286
|
-
let coordinator = gitDirtyCoordinatorsByCwd.get(key);
|
|
287
|
-
if (!coordinator) {
|
|
288
|
-
coordinator = new GitDirtyCoordinator(key);
|
|
289
|
-
gitDirtyCoordinatorsByCwd.set(key, coordinator);
|
|
290
|
-
}
|
|
291
|
-
return coordinator;
|
|
292
|
-
};
|
|
293
|
-
const releaseGitDirtyCoordinator = (coordinator) => {
|
|
294
|
-
if (coordinator.isEmpty)
|
|
295
|
-
gitDirtyCoordinatorsByCwd.delete(coordinator.cwd);
|
|
296
|
-
};
|
|
190
|
+
// CDP target paired with each WS via the {type:"identify"} handshake, so the
|
|
191
|
+
// manager's onClientExit hook can drive closeTab on a clean shell exit for
|
|
192
|
+
// that specific socket. Per-WS (a CDP target belongs to one page); cleared
|
|
193
|
+
// on detach.
|
|
194
|
+
const wsToTargetId = new Map();
|
|
297
195
|
const cdpBackgroundTabsDisabled = process.env.LOCALTERM_DISABLE_CDP_TABS === "1";
|
|
298
196
|
// One persistent CDP socket for the daemon's lifetime — opened once at start
|
|
299
197
|
// (below), so the user clears the browser's remote-debugging prompt a single
|
|
@@ -497,6 +395,17 @@ export const createServer = async (options = {}) => {
|
|
|
497
395
|
};
|
|
498
396
|
const api = new Hono();
|
|
499
397
|
api.get("/health", (context) => context.json({ ok: true, sessions: registry.size() }));
|
|
398
|
+
// The session picker: every live PTY (attached or dormant), so a tab can
|
|
399
|
+
// switch to one by id or kill one it no longer wants. `clients` is the count
|
|
400
|
+
// of attached sockets — 0 marks a dormant shell left behind by a closed tab,
|
|
401
|
+
// which is exactly the row the picker exists to surface.
|
|
402
|
+
api.get("/sessions", (context) => context.json({ sessions: registry.list() }));
|
|
403
|
+
api.delete("/sessions/:id", (context) => {
|
|
404
|
+
const killed = registry.kill(context.req.param("id"));
|
|
405
|
+
if (!killed)
|
|
406
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
407
|
+
return context.json({ ok: true });
|
|
408
|
+
});
|
|
500
409
|
// Same validation as the WS `?cwd=` param: must exist and be a directory.
|
|
501
410
|
// No path containment check — this daemon already hands out unrestricted
|
|
502
411
|
// shells, so reading a diff is not an escalation.
|
|
@@ -894,41 +803,18 @@ export const createServer = async (options = {}) => {
|
|
|
894
803
|
api.notFound((context) => context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND));
|
|
895
804
|
app.route("/api", api);
|
|
896
805
|
app.get("/ws", upgradeWebSocket((context) => {
|
|
897
|
-
let session = null;
|
|
898
806
|
let activeWs = null;
|
|
899
807
|
let claimedRunId = null;
|
|
900
|
-
// Server-side id
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
// spawning a fresh shell. Cleared on genuine shell exit (no reattach).
|
|
808
|
+
// Server-side id of the PTY this WS is attached to. Sent to the client in
|
|
809
|
+
// the {type:"session"} frame so a reconnect or switch carries it back as
|
|
810
|
+
// `?sid=` and the manager attaches to the live PTY instead of spawning.
|
|
904
811
|
let sessionId = null;
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
let
|
|
909
|
-
// The CDP targetId this WS socket was paired with via the
|
|
910
|
-
// `{type:"identify"}` handshake (page's ambient token →
|
|
911
|
-
// CdpClient.findTargetIdForToken). Set on identify; drives closeTab on
|
|
912
|
-
// clean shell exit. Stays null when no CDP is reachable, the token raced
|
|
913
|
-
// (page opened before the CdpClient observed it), or the page never
|
|
914
|
-
// re-identified.
|
|
915
|
-
let claimedTargetId = null;
|
|
916
|
-
// The per-cwd git-dirty coordinator this socket is currently subscribed
|
|
917
|
-
// to. Lives in this outer scope (not onOpen's) so the shared
|
|
918
|
-
// `releaseSessionFromSocket` finalization — called by both onClose and
|
|
919
|
-
// onError — can unsubscribe it. Moves whenever the session's cwd changes.
|
|
920
|
-
let gitDirtyCoordinator = null;
|
|
921
|
-
let drainPollTimer = null;
|
|
812
|
+
// The managed session this socket is attached to (null after detach). The
|
|
813
|
+
// manager owns the PTY's listeners, fan-out, and lifecycle; this reference
|
|
814
|
+
// is only for the heartbeat's pid label.
|
|
815
|
+
let managed = null;
|
|
922
816
|
let heartbeatTimer = null;
|
|
923
817
|
let stopHeartbeat = null;
|
|
924
|
-
let outputBatch = "";
|
|
925
|
-
let outputBatchTimer = null;
|
|
926
|
-
const stopDrainPoll = () => {
|
|
927
|
-
if (drainPollTimer === null)
|
|
928
|
-
return;
|
|
929
|
-
clearInterval(drainPollTimer);
|
|
930
|
-
drainPollTimer = null;
|
|
931
|
-
};
|
|
932
818
|
const stopHeartbeatChecks = () => {
|
|
933
819
|
if (heartbeatTimer !== null) {
|
|
934
820
|
clearInterval(heartbeatTimer);
|
|
@@ -949,42 +835,21 @@ export const createServer = async (options = {}) => {
|
|
|
949
835
|
}
|
|
950
836
|
};
|
|
951
837
|
// Single-shot finalization shared by onClose/onError (ws fires error then
|
|
952
|
-
// close on a transport failure; without this guard both would
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
// gone.
|
|
838
|
+
// close on a transport failure; without this guard both would detach
|
|
839
|
+
// twice). Detaches this socket from its PTY — the PTY itself stays alive
|
|
840
|
+
// (dormant if this was the last client) so the session picker can
|
|
841
|
+
// re-attach to it. The manager disposes the PTY on shell exit or kill.
|
|
957
842
|
let sessionFinalized = false;
|
|
958
843
|
const releaseSessionFromSocket = () => {
|
|
959
|
-
if (sessionFinalized)
|
|
960
|
-
return;
|
|
961
|
-
if (!session)
|
|
844
|
+
if (sessionFinalized || !activeWs)
|
|
962
845
|
return;
|
|
963
846
|
sessionFinalized = true;
|
|
964
|
-
const
|
|
965
|
-
registry.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
claimedRunId,
|
|
971
|
-
claimedTargetId,
|
|
972
|
-
automationId,
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
else {
|
|
976
|
-
live.dispose();
|
|
977
|
-
}
|
|
978
|
-
// Unsubscribe from the per-cwd git-dirty coordinator so a closed
|
|
979
|
-
// tab stops receiving (and stops keeping alive) broadcasts for its
|
|
980
|
-
// former cwd. `activeWs` is the socket this coordinator was added
|
|
981
|
-
// under; it's nulled below, so capture it first.
|
|
982
|
-
if (gitDirtyCoordinator && activeWs) {
|
|
983
|
-
gitDirtyCoordinator.remove(activeWs);
|
|
984
|
-
releaseGitDirtyCoordinator(gitDirtyCoordinator);
|
|
985
|
-
gitDirtyCoordinator = null;
|
|
986
|
-
}
|
|
987
|
-
session = null;
|
|
847
|
+
const ws = activeWs;
|
|
848
|
+
registry.detach(ws);
|
|
849
|
+
wsToTargetId.delete(ws);
|
|
850
|
+
clientSockets.delete(ws);
|
|
851
|
+
releaseRunTabHandle();
|
|
852
|
+
managed = null;
|
|
988
853
|
activeWs = null;
|
|
989
854
|
};
|
|
990
855
|
const rawCwd = context.req.query("cwd");
|
|
@@ -1016,37 +881,23 @@ export const createServer = async (options = {}) => {
|
|
|
1016
881
|
return;
|
|
1017
882
|
}
|
|
1018
883
|
}
|
|
1019
|
-
if
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
if (claimedRun)
|
|
1028
|
-
claimedRunId = claimedRun.runId;
|
|
1029
|
-
// Reattach: if the WS carries a `?sid=` for a PTY the pool still has
|
|
1030
|
-
// parked (transient drop — portless teardown on wake, brief network
|
|
1031
|
-
// blip), rebind the live Session to this socket instead of spawning
|
|
1032
|
-
// a new shell. A `claim()` miss (grace expired, or shell exited while
|
|
1033
|
-
// parked) falls through to the spawn path.
|
|
1034
|
-
const parked = requestedSid ? reattachPool.claim(requestedSid) : null;
|
|
1035
|
-
const isReattach = parked !== null;
|
|
1036
|
-
let liveSession;
|
|
1037
|
-
if (parked) {
|
|
1038
|
-
liveSession = parked.session;
|
|
1039
|
-
sessionId = parked.sid;
|
|
1040
|
-
claimedRunId = parked.claimedRunId;
|
|
1041
|
-
claimedTargetId = parked.claimedTargetId;
|
|
1042
|
-
automationId = parked.automationId;
|
|
1043
|
-
// Re-register so the live PTY counts toward MAX_CONCURRENT_SESSIONS
|
|
1044
|
-
// and caffeinate's ps-tree walk again. park() unregistered it on
|
|
1045
|
-
// the prior WS close; if we skipped this, a transient drop would
|
|
1046
|
-
// leave the PTY off the books until the next reconnect.
|
|
1047
|
-
registry.register(liveSession);
|
|
884
|
+
// Reattach if `?sid=` names a PTY the manager still has live (a
|
|
885
|
+
// transient drop, or a switch from the session picker). A miss
|
|
886
|
+
// (shell exited while dormant, killed, or reaped by the idle
|
|
887
|
+
// sweep) falls through to a fresh spawn.
|
|
888
|
+
const attached = requestedSid ? registry.attach(ws, requestedSid) : null;
|
|
889
|
+
if (attached) {
|
|
890
|
+
managed = attached;
|
|
891
|
+
sessionId = attached.id;
|
|
1048
892
|
}
|
|
1049
893
|
else {
|
|
894
|
+
if (registry.atCapacity()) {
|
|
895
|
+
ws.close(WS_CLOSE_CAPACITY_REACHED, "session capacity reached");
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
// Claims are single-use: a reload of a ?run= tab gets a plain
|
|
899
|
+
// shell in the same cwd instead of re-running the scheduled command.
|
|
900
|
+
const claimedRun = requestedRunId ? automationRunTracker.claim(requestedRunId) : null;
|
|
1050
901
|
let sessionCwd = requestedCwd;
|
|
1051
902
|
if (claimedRun) {
|
|
1052
903
|
try {
|
|
@@ -1057,39 +908,29 @@ export const createServer = async (options = {}) => {
|
|
|
1057
908
|
/* automation cwd vanished since creation; fall back to default */
|
|
1058
909
|
}
|
|
1059
910
|
}
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
});
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
status: "running",
|
|
1076
|
-
startedAt: Date.now(),
|
|
1077
|
-
});
|
|
1078
|
-
broadcastAutomations();
|
|
1079
|
-
}
|
|
1080
|
-
if (isAutomationSession) {
|
|
1081
|
-
liveSession.on("automation-exit", (exitCode) => {
|
|
1082
|
-
automationStore.updateRun(automationId, automationRunId, {
|
|
1083
|
-
status: exitCode === 0 ? "completed" : "failed",
|
|
1084
|
-
exitCode,
|
|
1085
|
-
finishedAt: Date.now(),
|
|
911
|
+
const automation = claimedRun
|
|
912
|
+
? { automationId: claimedRun.automationId, runId: claimedRun.runId }
|
|
913
|
+
: undefined;
|
|
914
|
+
const spawned = registry.spawnAndAttach(ws, { cwd: sessionCwd, initialCommand: claimedRun?.command ?? requestedInitialCommand }, automation);
|
|
915
|
+
if (!spawned) {
|
|
916
|
+
ws.close(WS_CLOSE_CAPACITY_REACHED, "session capacity reached");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
managed = spawned;
|
|
920
|
+
sessionId = spawned.id;
|
|
921
|
+
if (claimedRun) {
|
|
922
|
+
claimedRunId = claimedRun.runId;
|
|
923
|
+
automationStore.updateRun(claimedRun.automationId, claimedRun.runId, {
|
|
924
|
+
status: "running",
|
|
925
|
+
startedAt: Date.now(),
|
|
1086
926
|
});
|
|
1087
927
|
broadcastAutomations();
|
|
1088
|
-
|
|
1089
|
-
folderWatchManager.notifyRunFinished(automationId);
|
|
1090
|
-
sessionEventManager.notifyRunFinished(automationId);
|
|
1091
|
-
});
|
|
928
|
+
}
|
|
1092
929
|
}
|
|
930
|
+
if (!managed)
|
|
931
|
+
return;
|
|
932
|
+
clientSockets.add(ws);
|
|
933
|
+
const liveSession = managed.session;
|
|
1093
934
|
// Heartbeat. Without this, half-open sockets (laptop sleep, network
|
|
1094
935
|
// dropout) never surface as a `close` event and the daemon keeps
|
|
1095
936
|
// streaming PTY output into the void. We only enable it if the raw
|
|
@@ -1102,9 +943,7 @@ export const createServer = async (options = {}) => {
|
|
|
1102
943
|
// sleep but the loopback socket itself never dropped), we send one
|
|
1103
944
|
// fresh ping and wait through WS_HEARTBEAT_GRACE_MS for a pong before
|
|
1104
945
|
// terminating. A live socket pongs inside the grace window; a truly
|
|
1105
|
-
// half-open one stays silent and terminates on the next tick.
|
|
1106
|
-
// avoids killing sessions that survived a brief laptop sleep, while
|
|
1107
|
-
// still tearing down genuinely dead sockets within ~one extra tick.
|
|
946
|
+
// half-open one stays silent and terminates on the next tick.
|
|
1108
947
|
let lastPongAt = Date.now();
|
|
1109
948
|
let pendingPingAt = 0;
|
|
1110
949
|
stopHeartbeat = onRawEvent(ws.raw, "pong", () => {
|
|
@@ -1135,165 +974,6 @@ export const createServer = async (options = {}) => {
|
|
|
1135
974
|
}, WS_HEARTBEAT_INTERVAL_MS);
|
|
1136
975
|
heartbeatTimer.unref?.();
|
|
1137
976
|
}
|
|
1138
|
-
// Outbound flow control. When the WS buffer climbs past the high
|
|
1139
|
-
// water mark we pause the PTY (OS pipe back-pressure stops the
|
|
1140
|
-
// child process producing more output) and start polling for the
|
|
1141
|
-
// buffer to drain back below the low water mark. This way bursty
|
|
1142
|
-
// output (`cat`, build logs, npm install) doesn't kill the
|
|
1143
|
-
// connection — only a genuinely wedged receiver eventually trips
|
|
1144
|
-
// the WS_BACKPRESSURE_THRESHOLD_BYTES emergency in safeSend.
|
|
1145
|
-
const ensureDrainPoll = () => {
|
|
1146
|
-
if (drainPollTimer !== null)
|
|
1147
|
-
return;
|
|
1148
|
-
drainPollTimer = setInterval(() => {
|
|
1149
|
-
if (!liveSession.isPaused) {
|
|
1150
|
-
stopDrainPoll();
|
|
1151
|
-
return;
|
|
1152
|
-
}
|
|
1153
|
-
if (getRawBufferedAmount(ws.raw) <= WS_OUTBOUND_RESUME_LOW_WATER_BYTES) {
|
|
1154
|
-
liveSession.resume();
|
|
1155
|
-
stopDrainPoll();
|
|
1156
|
-
}
|
|
1157
|
-
}, WS_OUTBOUND_DRAIN_POLL_MS);
|
|
1158
|
-
drainPollTimer.unref?.();
|
|
1159
|
-
};
|
|
1160
|
-
// Drain-and-pause for the timer-driven and threshold flushes; the per-
|
|
1161
|
-
// session backpressure pause check lives here (and only here — the
|
|
1162
|
-
// onClose/onError teardown paths skip it via sendOutputBatchBytes
|
|
1163
|
-
// directly).
|
|
1164
|
-
const drainOutputBatch = (target) => {
|
|
1165
|
-
sendOutputBatchBytes(target, outputBatch);
|
|
1166
|
-
outputBatch = "";
|
|
1167
|
-
if (!liveSession.isPaused &&
|
|
1168
|
-
getRawBufferedAmount(target.raw) >= WS_OUTBOUND_PAUSE_HIGH_WATER_BYTES) {
|
|
1169
|
-
liveSession.pause();
|
|
1170
|
-
ensureDrainPoll();
|
|
1171
|
-
}
|
|
1172
|
-
};
|
|
1173
|
-
const flushOutputBatch = () => {
|
|
1174
|
-
outputBatchTimer = null;
|
|
1175
|
-
drainOutputBatch(ws);
|
|
1176
|
-
};
|
|
1177
|
-
// Wire listeners so any emit from Session (current or future)
|
|
1178
|
-
// reaches the client. Today node-pty's data/exit are async, but
|
|
1179
|
-
// this guards against drift.
|
|
1180
|
-
const onOutput = (data) => {
|
|
1181
|
-
outputBatch += data;
|
|
1182
|
-
registry.noteOutput(liveSession.pid);
|
|
1183
|
-
caffeinateManager.noteOutputActivity();
|
|
1184
|
-
if (outputBatch.length >= OUTPUT_BATCH_FLUSH_BYTES) {
|
|
1185
|
-
if (outputBatchTimer !== null) {
|
|
1186
|
-
clearTimeout(outputBatchTimer);
|
|
1187
|
-
outputBatchTimer = null;
|
|
1188
|
-
}
|
|
1189
|
-
flushOutputBatch();
|
|
1190
|
-
}
|
|
1191
|
-
else if (outputBatchTimer === null) {
|
|
1192
|
-
outputBatchTimer = setTimeout(flushOutputBatch, OUTPUT_BATCH_WINDOW_MS);
|
|
1193
|
-
}
|
|
1194
|
-
};
|
|
1195
|
-
const onTitle = (title) => safeSend(ws, { type: "title", title });
|
|
1196
|
-
const onCwd = (cwd) => safeSend(ws, { type: "cwd", cwd });
|
|
1197
|
-
const onForeground = (process) => {
|
|
1198
|
-
safeSend(ws, { type: "foreground", process });
|
|
1199
|
-
// A foreground transition is the cheap signal that a recognized
|
|
1200
|
-
// program may have started or stopped — nudge automatic detection.
|
|
1201
|
-
caffeinateManager.pokeAuto();
|
|
1202
|
-
};
|
|
1203
|
-
const onNotification = (body) => safeSend(ws, { type: "notification", body });
|
|
1204
|
-
const onExit = (code) => {
|
|
1205
|
-
// Reliable closeTab on a clean shell exit for CDP-controlled tabs.
|
|
1206
|
-
// closeTab drives the browser's own close path via CDP instead of
|
|
1207
|
-
// relying on the client's window.close() — which often doesn't
|
|
1208
|
-
// apply (Dia/Arc, or a tab the user opened by URL rather than via
|
|
1209
|
-
// window.open) and strands the tab. Fire-and-forget onto the same
|
|
1210
|
-
// closeQueue that serializes automation-run closes, so concurrent
|
|
1211
|
-
// Ctrl+Ds across tabs never interleave and orphan targets.
|
|
1212
|
-
// Skipped on non-zero exit codes so the dead-session mask surfaces
|
|
1213
|
-
// the failure instead of closing the tab silently.
|
|
1214
|
-
if (claimedTargetId && (code === null || code === 0)) {
|
|
1215
|
-
void cdpClient?.closeTab(claimedTargetId);
|
|
1216
|
-
}
|
|
1217
|
-
if (outputBatchTimer !== null) {
|
|
1218
|
-
clearTimeout(outputBatchTimer);
|
|
1219
|
-
outputBatchTimer = null;
|
|
1220
|
-
}
|
|
1221
|
-
flushOutputBatch();
|
|
1222
|
-
stopDrainPoll();
|
|
1223
|
-
stopHeartbeatChecks();
|
|
1224
|
-
gitDiffWatcher.dispose();
|
|
1225
|
-
safeSend(ws, { type: "exit", code });
|
|
1226
|
-
ws.close();
|
|
1227
|
-
};
|
|
1228
|
-
const gitDiffWatcher = new GitDiffWatcher();
|
|
1229
|
-
// Subscribe this tab to the per-cwd git-dirty coordinator so a git
|
|
1230
|
-
// change observed by any tab in the same cwd (its prompt hook or fs
|
|
1231
|
-
// watcher) refreshes this tab too. The coordinator dedups the
|
|
1232
|
-
// summary computation and broadcasts the result to every subscriber.
|
|
1233
|
-
gitDirtyCoordinator = coordinatorForCwd(liveSession.cwd);
|
|
1234
|
-
gitDirtyCoordinator.add(ws);
|
|
1235
|
-
const signalGitDirty = () => {
|
|
1236
|
-
const cwd = liveSession.lastEmittedCwd;
|
|
1237
|
-
if (!cwd)
|
|
1238
|
-
return;
|
|
1239
|
-
coordinatorForCwd(cwd).signal();
|
|
1240
|
-
};
|
|
1241
|
-
gitDiffWatcher.on("git-dirty", () => {
|
|
1242
|
-
signalGitDirty();
|
|
1243
|
-
});
|
|
1244
|
-
const gitAutomationEvents = GIT_DIFF_WATCHER_EVENT_NAMES.filter((eventName) => eventName !== "git-dirty");
|
|
1245
|
-
for (const eventName of gitAutomationEvents) {
|
|
1246
|
-
gitDiffWatcher.on(eventName, () => {
|
|
1247
|
-
if (!isAutomationSession) {
|
|
1248
|
-
sessionEventManager.onSessionEvent(eventName, liveSession.lastEmittedCwd);
|
|
1249
|
-
}
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
gitDiffWatcher.start(liveSession.cwd);
|
|
1253
|
-
// Automation-run sessions should not feed events into the session
|
|
1254
|
-
// event manager — only user-driven sessions count.
|
|
1255
|
-
liveSession.on("git-dirty", () => {
|
|
1256
|
-
signalGitDirty();
|
|
1257
|
-
if (!isAutomationSession) {
|
|
1258
|
-
sessionEventManager.onSessionEvent("git-dirty", liveSession.lastEmittedCwd);
|
|
1259
|
-
}
|
|
1260
|
-
});
|
|
1261
|
-
liveSession.on("cwd", (changedCwd) => {
|
|
1262
|
-
gitDiffWatcher.stop();
|
|
1263
|
-
gitDiffWatcher.start(changedCwd);
|
|
1264
|
-
const nextCoordinator = coordinatorForCwd(changedCwd);
|
|
1265
|
-
const current = gitDirtyCoordinator;
|
|
1266
|
-
if (current && nextCoordinator !== current) {
|
|
1267
|
-
current.remove(ws);
|
|
1268
|
-
releaseGitDirtyCoordinator(current);
|
|
1269
|
-
gitDirtyCoordinator = nextCoordinator;
|
|
1270
|
-
nextCoordinator.add(ws);
|
|
1271
|
-
}
|
|
1272
|
-
if (!isAutomationSession) {
|
|
1273
|
-
sessionEventManager.onSessionEvent("cwd", changedCwd);
|
|
1274
|
-
}
|
|
1275
|
-
});
|
|
1276
|
-
liveSession.on("output", onOutput);
|
|
1277
|
-
liveSession.on("title", onTitle);
|
|
1278
|
-
liveSession.on("cwd", onCwd);
|
|
1279
|
-
liveSession.on("foreground", (foregroundProcess) => {
|
|
1280
|
-
onForeground(foregroundProcess);
|
|
1281
|
-
if (!isAutomationSession) {
|
|
1282
|
-
sessionEventManager.onSessionEvent("foreground", liveSession.lastEmittedCwd);
|
|
1283
|
-
}
|
|
1284
|
-
});
|
|
1285
|
-
liveSession.on("notification", (body) => {
|
|
1286
|
-
onNotification(body);
|
|
1287
|
-
if (!isAutomationSession) {
|
|
1288
|
-
sessionEventManager.onSessionEvent("notification", liveSession.lastEmittedCwd);
|
|
1289
|
-
}
|
|
1290
|
-
});
|
|
1291
|
-
liveSession.on("exit", (code) => {
|
|
1292
|
-
onExit(code);
|
|
1293
|
-
if (!isAutomationSession && liveSession.lastEmittedCwd) {
|
|
1294
|
-
sessionEventManager.onSessionEvent("exit", liveSession.lastEmittedCwd);
|
|
1295
|
-
}
|
|
1296
|
-
});
|
|
1297
977
|
safeSend(ws, {
|
|
1298
978
|
type: "session",
|
|
1299
979
|
shell: liveSession.shell,
|
|
@@ -1308,8 +988,9 @@ export const createServer = async (options = {}) => {
|
|
|
1308
988
|
safeSend(ws, caffeinateStatePayload());
|
|
1309
989
|
},
|
|
1310
990
|
onMessage(event) {
|
|
1311
|
-
if (!
|
|
991
|
+
if (!activeWs)
|
|
1312
992
|
return;
|
|
993
|
+
const ws = activeWs;
|
|
1313
994
|
let rawPayload;
|
|
1314
995
|
try {
|
|
1315
996
|
const raw = typeof event.data === "string" ? event.data : event.data.toString();
|
|
@@ -1322,7 +1003,16 @@ export const createServer = async (options = {}) => {
|
|
|
1322
1003
|
if (!parsed.success)
|
|
1323
1004
|
return;
|
|
1324
1005
|
if (parsed.data.type === "input") {
|
|
1325
|
-
|
|
1006
|
+
registry.writeInput(ws, parsed.data.data);
|
|
1007
|
+
}
|
|
1008
|
+
else if (parsed.data.type === "resize") {
|
|
1009
|
+
registry.resize(ws, parsed.data.cols, parsed.data.rows, parsed.data.pixelWidth, parsed.data.pixelHeight);
|
|
1010
|
+
}
|
|
1011
|
+
else if (parsed.data.type === "ready") {
|
|
1012
|
+
// Attach handshake: the client has the {type:"session"} frame and
|
|
1013
|
+
// says whether it wants the scrollback replay (a switch to a PTY
|
|
1014
|
+
// it didn't already have on screen) before live fan-out begins.
|
|
1015
|
+
registry.promote(ws, parsed.data.replay);
|
|
1326
1016
|
}
|
|
1327
1017
|
else if (parsed.data.type === "caffeinate-mode") {
|
|
1328
1018
|
caffeinateManager.setMode(parsed.data.mode);
|
|
@@ -1339,8 +1029,8 @@ export const createServer = async (options = {}) => {
|
|
|
1339
1029
|
else if (parsed.data.type === "identify") {
|
|
1340
1030
|
// Ambient tab provenance: the page echoes the CDP-injected token so
|
|
1341
1031
|
// we pair this socket with its targetId for closeTab on shell exit.
|
|
1342
|
-
// `token:null` means injection hasn't landed yet (page opened its
|
|
1343
|
-
//
|
|
1032
|
+
// `token:null` means injection hasn't landed yet (page opened its WS
|
|
1033
|
+
// before the CdpClient observed it) — wait for the page to
|
|
1344
1034
|
// re-identify on the 'localterm-token' event rather than pairing
|
|
1345
1035
|
// eagerly against a null token. We always ack the client either
|
|
1346
1036
|
// way so its markShellDead path knows whether to fall back to
|
|
@@ -1349,58 +1039,31 @@ export const createServer = async (options = {}) => {
|
|
|
1349
1039
|
if (token !== null) {
|
|
1350
1040
|
const targetId = cdpClient?.findTargetIdForToken(token);
|
|
1351
1041
|
if (targetId)
|
|
1352
|
-
|
|
1042
|
+
wsToTargetId.set(ws, targetId);
|
|
1353
1043
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
});
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
else {
|
|
1362
|
-
session.resize(parsed.data.cols, parsed.data.rows, parsed.data.pixelWidth, parsed.data.pixelHeight);
|
|
1044
|
+
safeSend(ws, {
|
|
1045
|
+
type: "cdp-controlled",
|
|
1046
|
+
controlled: wsToTargetId.has(ws),
|
|
1047
|
+
});
|
|
1363
1048
|
}
|
|
1364
1049
|
},
|
|
1365
1050
|
onClose(event) {
|
|
1366
|
-
if (outputBatchTimer !== null) {
|
|
1367
|
-
clearTimeout(outputBatchTimer);
|
|
1368
|
-
outputBatchTimer = null;
|
|
1369
|
-
}
|
|
1370
|
-
if (outputBatch && activeWs) {
|
|
1371
|
-
sendOutputBatchBytes(activeWs, outputBatch);
|
|
1372
|
-
outputBatch = "";
|
|
1373
|
-
}
|
|
1374
|
-
stopDrainPoll();
|
|
1375
1051
|
stopHeartbeatChecks();
|
|
1376
1052
|
// Most "the terminal randomly died" reports are actually the WS
|
|
1377
1053
|
// closing for a reason we never surfaced; logging code+reason+
|
|
1378
1054
|
// wasClean here makes the next incident a 1-line lookup in
|
|
1379
1055
|
// ~/.localterm/server.log.
|
|
1380
|
-
const pidLabel =
|
|
1056
|
+
const pidLabel = managed ? ` pid ${managed.session.pid}` : "";
|
|
1381
1057
|
console.info(`ws closed${pidLabel}: code=${event.code} reason=${JSON.stringify(event.reason)} wasClean=${event.wasClean}`);
|
|
1382
|
-
if (activeWs)
|
|
1383
|
-
clientSockets.delete(activeWs);
|
|
1384
1058
|
releaseRunTabHandle();
|
|
1385
1059
|
releaseSessionFromSocket();
|
|
1386
1060
|
},
|
|
1387
1061
|
onError(event) {
|
|
1388
|
-
if (outputBatchTimer !== null) {
|
|
1389
|
-
clearTimeout(outputBatchTimer);
|
|
1390
|
-
outputBatchTimer = null;
|
|
1391
|
-
}
|
|
1392
|
-
if (outputBatch && activeWs) {
|
|
1393
|
-
sendOutputBatchBytes(activeWs, outputBatch);
|
|
1394
|
-
outputBatch = "";
|
|
1395
|
-
}
|
|
1396
|
-
stopDrainPoll();
|
|
1397
1062
|
stopHeartbeatChecks();
|
|
1398
1063
|
const errorValue = event && typeof event === "object" ? (Reflect.get(event, "error") ?? event) : event;
|
|
1399
1064
|
const message = errorValue instanceof Error ? errorValue.message : String(errorValue);
|
|
1400
|
-
const pidLabel =
|
|
1065
|
+
const pidLabel = managed ? ` pid ${managed.session.pid}` : "";
|
|
1401
1066
|
console.error(`ws error${pidLabel}: ${message}`);
|
|
1402
|
-
if (activeWs)
|
|
1403
|
-
clientSockets.delete(activeWs);
|
|
1404
1067
|
releaseRunTabHandle();
|
|
1405
1068
|
releaseSessionFromSocket();
|
|
1406
1069
|
},
|
|
@@ -1505,7 +1168,6 @@ export const createServer = async (options = {}) => {
|
|
|
1505
1168
|
caffeinateManager.dispose();
|
|
1506
1169
|
cdpClient?.close();
|
|
1507
1170
|
registry.disposeAll();
|
|
1508
|
-
reattachPool.disposeAll();
|
|
1509
1171
|
// Forcibly tear down every WS first. node-pty + ws upgraded sockets
|
|
1510
1172
|
// aren't tracked in http.Server's keep-alive set, so target.close() would
|
|
1511
1173
|
// otherwise wait forever for them and the CLI's force-exit fallback would
|