termdock 1.4.151 → 1.4.153
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/client/assets/{ModelPreview-D-Wk-pU3.js → ModelPreview-B4Scg6qI.js} +1 -1
- package/dist/client/assets/{abnfDiagram-VRR7QNED-Mi1zHHiH.js → abnfDiagram-VRR7QNED-BKxYTem8.js} +1 -1
- package/dist/client/assets/{arc-EGqDv32w.js → arc-QNrFJtVM.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-Ct9QZZEH.js → architectureDiagram-ZJ3FMSHR-C49DATUA.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-Ca50Ru9w.js → blockDiagram-677ZJIJ3-KvCVYAmU.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-i5kHzR7D.js → c4Diagram-LMCZKHZV-DMHaRUAq.js} +1 -1
- package/dist/client/assets/channel-IfUCcv80.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-CIM1aS9l.js → chunk-2Q5K7J3B-NP5gy8Wm.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-D_WyFD7b.js → chunk-32BRIVSS-BLTpTLmX.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-D8A1uIh4.js → chunk-5VM5RSS4-BzDfiUPg.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BY-EsHV1.js → chunk-EX3LRPZG-CSmyg32F.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-BllvB6Kn.js → chunk-JWPE2WC7-DQmy3r-2.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-7UGr_Qhj.js → chunk-MOJQB5TN-BiT0ZT3U.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-Bm4GI-Aw.js → chunk-RYQCIY6F--Eap7GWU.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-DXv6flhu.js → chunk-V7JOEXUC-DawXSnfn.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-Bhx5tYD7.js → chunk-VR4S4FIN-Yy0GSXVy.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-D3tf4DOw.js → chunk-XXDRQBXY-CiUcnZJ9.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CTnNs1HI.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CTnNs1HI.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-lI9GQDjH.js → cose-bilkent-JH36ORCC-BhXShZCd.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-Cm_6Lfp5.js → cynefin-VYW2F7L2-7JnEXayE.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BW7CGtNJ.js → cynefinDiagram-TSTJHNR4-y_rQrpK6.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-DE4gaR5X.js → dagre-VKFMJZFB-Cza74CPv.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-g1Pvx7c1.js → diagram-FQU43EPY-DCy_e4Kv.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-CK0bbu8M.js → diagram-G47NLZAW-B8GTUDfW.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-DMyfIADQ.js → diagram-NH7WQ7WH-B9ohNkBT.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-Dq7e34Im.js → diagram-OA4YK3LP-BQm0kwc8.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-C1s-Xu2N.js → diagram-WEI45ONY-hs43w1WR.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-D2tNgNck.js → ebnfDiagram-CCIWWBDH-C3oeagge.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-D6atD1NP.js → erDiagram-Q63AITRT-xs6jNwv6.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-DCBHg7Ti.js → flowDiagram-23GEKE2U-j8gMnGkL.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-DXQuYslQ.js → ganttDiagram-NO4QXBWP-rhUjTc6Z.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Dwd_0ioG.js → gitGraphDiagram-IHSO6WYX-EAEhIsm0.js} +1 -1
- package/dist/client/assets/{index-CS2iJEaF.js → index-BiZD1WJ9.js} +100 -100
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-NiN9DrMH.js → infoDiagram-FWYZ7A6U-C9i2U3Oz.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-BMJhICr3.js → ishikawaDiagram-FXEZZL3T-CE0ijWcu.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-CLdmCiM-.js → journeyDiagram-5HDEW3XC-BtScT2V2.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-2IXGOXW3.js → kanban-definition-HUTT4EX6-6cpblqEp.js} +1 -1
- package/dist/client/assets/{linear-D_3w4iPe.js → linear-DieXAo56.js} +1 -1
- package/dist/client/assets/{mermaid.core-ChWJBBCP.js → mermaid.core-DRr9veKI.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-CWECsNJN.js → mindmap-definition-LN4V7U3C-Dngg9Tgg.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-C9cSRovE.js → pegDiagram-2B236MQR-C4sXNX41.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-C-zXtuDA.js → pieDiagram-ENE6RG2P-uJsTiMwN.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CQ7KPXB8.js → quadrantDiagram-ABIIQ3AL-cNsZG5Hs.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-DHWRdUKV.js → railroadDiagram-RFXS5EU6-D_gQTLhO.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-B6VYiC2A.js → requirementDiagram-TGXJPOKE-CDokTVFx.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-3faNrLBq.js → sankeyDiagram-HTMAVEWB-Ch8GkIcG.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-CQH7a3Ge.js → sequenceDiagram-DBY2YBRQ-DveOot48.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-Dz_XYFOF.js → sizeCapture-X5ZJPWSS-bZQXs6R5.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-DxOPhaqg.js → stateDiagram-2N3HPSRC-DwDglXSo.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-lkFzzYeA.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-DP3zwOsK.js → swimlanes-5IMT3BWC-CLANw92C.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-CzAWXOcq.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-CouI6wN1.js → timeline-definition-FHXFAJF6-CdIW-Srj.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-CqylKRdX.js → vennDiagram-L72KCM5P-CkH9lpKn.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-ByhFP--3.js → wardleyDiagram-EHGQE667-CJqtOyWX.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-BZs9a8GL.js → xychartDiagram-FW5EYKEG-DwJ1QEPf.js} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/client/sw.js +1 -1
- package/dist/server/routes/filesystem.js +215 -144
- package/dist/server/routes/terminal.js +5 -7
- package/dist/server/utils/fileWatchPolicy.js +22 -15
- package/dist/server/utils/httpResponse.js +14 -0
- package/package.json +1 -2
- package/runtime-manifest.json +3 -3
- package/dist/client/assets/channel-C_ptNcbw.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-Dn0QXhmh.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-Dn0QXhmh.js +0 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Bu9Vt-0n.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-ClbSzPVW.js +0 -8
|
@@ -4,7 +4,6 @@ import fs from 'fs';
|
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { execFile, spawn } from 'child_process';
|
|
7
|
-
import watcher from '@parcel/watcher';
|
|
8
7
|
import busboy from 'busboy';
|
|
9
8
|
import { pathValidator } from '../utils/pathValidator.js';
|
|
10
9
|
import { isAuthEnabled, isRequestAuthenticated } from '../utils/authProtection.js';
|
|
@@ -19,7 +18,8 @@ import { inspectKicadBoardPoint } from '../utils/kicadBoardInspection.js';
|
|
|
19
18
|
import { appendRipgrepExcludeArgs, createExcludeMatcher, normalizeExcludePatterns } from '../utils/fileSearchOptions.js';
|
|
20
19
|
import { EdaPreviewCache, requestAcceptsEtag } from '../utils/edaPreviewCache.js';
|
|
21
20
|
import { convertHeicPreview, HEIC_PREVIEW_MIME_TYPES, isHeicPreviewPath } from '../utils/heicPreview.js';
|
|
22
|
-
import { getWatchErrorCode, enqueueLatestWatchEvent, inspectLinuxInotifyUsage, isWatchResourceExhaustion,
|
|
21
|
+
import { getWatchErrorCode, diffWatchSnapshots, enqueueLatestWatchEvent, inspectLinuxInotifyUsage, isWatchResourceExhaustion, normalizeDirectoryWatchRoots, resolveDirectWatchEventPath, WATCH_IGNORED_NAMES, WATCH_RESOURCE_BACKOFF_MS, } from '../utils/fileWatchPolicy.js';
|
|
22
|
+
import { isResponseWritable, writeResponseChunk } from '../utils/httpResponse.js';
|
|
23
23
|
const router = Router();
|
|
24
24
|
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
|
|
25
25
|
const MAX_IMAGE_PREVIEW_SIZE = 20 * 1024 * 1024; // 20MB
|
|
@@ -61,13 +61,15 @@ const FS_IO_LOG_NAME = 'fs-io.log';
|
|
|
61
61
|
const activeDiffSlots = new Map();
|
|
62
62
|
const activeIoSlots = new Map();
|
|
63
63
|
const sharedWatchRegistry = new Map();
|
|
64
|
-
const SHARED_WATCH_EVICT_DELAY_MS =
|
|
64
|
+
const SHARED_WATCH_EVICT_DELAY_MS = 60_000;
|
|
65
65
|
const WATCH_STAT_CONCURRENCY = 8;
|
|
66
|
+
const WATCH_POLL_INTERVAL_MS = 4_000;
|
|
67
|
+
const MAX_NATIVE_DIRECTORY_WATCHES = 256;
|
|
66
68
|
let watchAdmissionBlockedUntil = 0;
|
|
67
69
|
function countActiveSharedWatches() {
|
|
68
70
|
let count = 0;
|
|
69
71
|
for (const entry of sharedWatchRegistry.values()) {
|
|
70
|
-
if (entry.
|
|
72
|
+
if (entry.nativeWatcher)
|
|
71
73
|
count += 1;
|
|
72
74
|
}
|
|
73
75
|
return count;
|
|
@@ -114,76 +116,123 @@ function drainSharedWatchEvents(entry) {
|
|
|
114
116
|
});
|
|
115
117
|
}
|
|
116
118
|
}
|
|
117
|
-
function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
119
|
+
function enqueueSharedNativeEvent(rootPath, entry, filename) {
|
|
120
|
+
if (!filename) {
|
|
121
|
+
entry.pendingEvents.clear();
|
|
122
|
+
entry.eventGeneration += 1;
|
|
123
|
+
broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'directory-rescan' });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const changedPath = resolveDirectWatchEventPath(rootPath, filename);
|
|
127
|
+
if (!changedPath || isIgnoredWatchPath(rootPath, changedPath))
|
|
128
|
+
return;
|
|
129
|
+
if (entry.clients.size === 0) {
|
|
130
|
+
entry.needsRescanOnAttach = true;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (enqueueLatestWatchEvent(entry.pendingEvents, changedPath, 'update', WATCH_EVENT_STORM_LIMIT) === 'overflow') {
|
|
134
|
+
entry.pendingEvents.clear();
|
|
135
|
+
entry.eventGeneration += 1;
|
|
136
|
+
broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'event-storm' });
|
|
137
|
+
return;
|
|
136
138
|
}
|
|
137
139
|
drainSharedWatchEvents(entry);
|
|
138
140
|
}
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
activeSubscriptions: countActiveSharedWatches(),
|
|
155
|
-
registrySize: sharedWatchRegistry.size,
|
|
156
|
-
resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
|
|
157
|
-
error: reason,
|
|
158
|
-
});
|
|
159
|
-
const failedSubscription = entry.subscription;
|
|
160
|
-
entry.subscription = null;
|
|
161
|
-
void failedSubscription?.unsubscribe().catch(() => undefined);
|
|
162
|
-
if (resourceExhausted) {
|
|
163
|
-
void inspectLinuxInotifyUsage().then((diagnostics) => {
|
|
164
|
-
if (diagnostics)
|
|
165
|
-
console.warn('[file-watch] Linux inotify usage after callback failure', diagnostics);
|
|
166
|
-
}).catch(() => undefined);
|
|
141
|
+
async function readDirectorySnapshot(rootPath) {
|
|
142
|
+
const dirents = await fs.promises.readdir(rootPath, { withFileTypes: true });
|
|
143
|
+
const visible = dirents.filter((dirent) => !WATCH_IGNORED_NAMES.has(dirent.name));
|
|
144
|
+
const snapshot = new Map();
|
|
145
|
+
let cursor = 0;
|
|
146
|
+
const workers = Array.from({ length: Math.min(WATCH_STAT_CONCURRENCY, visible.length) }, async () => {
|
|
147
|
+
while (cursor < visible.length) {
|
|
148
|
+
const dirent = visible[cursor++];
|
|
149
|
+
const entryPath = path.join(rootPath, dirent.name);
|
|
150
|
+
try {
|
|
151
|
+
const stat = await fs.promises.lstat(entryPath);
|
|
152
|
+
snapshot.set(entryPath, {
|
|
153
|
+
signature: `${stat.mode}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`,
|
|
154
|
+
entry: toFileEntry(entryPath, stat, dirent.isSymbolicLink()),
|
|
155
|
+
});
|
|
167
156
|
}
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (getWatchErrorCode(error) !== 'ENOENT')
|
|
159
|
+
throw error;
|
|
171
160
|
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
await Promise.all(workers);
|
|
164
|
+
return snapshot;
|
|
165
|
+
}
|
|
166
|
+
function scheduleSharedWatchPoll(rootPath, entry, delay = WATCH_POLL_INTERVAL_MS) {
|
|
167
|
+
if (entry.pollTimer || entry.mode !== 'polling' || entry.refs === 0)
|
|
168
|
+
return;
|
|
169
|
+
entry.pollTimer = setTimeout(() => {
|
|
170
|
+
entry.pollTimer = null;
|
|
171
|
+
void pollSharedWatch(rootPath, entry);
|
|
172
|
+
}, delay);
|
|
173
|
+
entry.pollTimer.unref?.();
|
|
174
|
+
}
|
|
175
|
+
async function pollSharedWatch(rootPath, entry) {
|
|
176
|
+
if (entry.mode !== 'polling' || entry.refs === 0)
|
|
177
|
+
return;
|
|
178
|
+
try {
|
|
179
|
+
const next = await readDirectorySnapshot(rootPath);
|
|
180
|
+
const previous = entry.pollSnapshot;
|
|
181
|
+
entry.pollSnapshot = next;
|
|
182
|
+
entry.failure = null;
|
|
183
|
+
if (previous) {
|
|
184
|
+
for (const change of diffWatchSnapshots(previous, next)) {
|
|
185
|
+
broadcastSharedWatchEvent(entry, {
|
|
186
|
+
type: change.type === 'delete' ? 'deleted' : change.type === 'create' ? 'created' : 'updated',
|
|
187
|
+
path: change.path,
|
|
188
|
+
entry: change.value?.entry,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
entry.failure = error instanceof Error ? error.message : String(error);
|
|
195
|
+
if (getWatchErrorCode(error) === 'ENOENT') {
|
|
196
|
+
for (const client of [...entry.clients])
|
|
197
|
+
if (!client.closed)
|
|
198
|
+
client.degrade(entry.failure);
|
|
172
199
|
return;
|
|
173
200
|
}
|
|
174
|
-
|
|
175
|
-
}
|
|
201
|
+
console.warn('[file-watch] Polling refresh failed; will retry', { rootPath, error: entry.failure });
|
|
202
|
+
}
|
|
203
|
+
scheduleSharedWatchPoll(rootPath, entry);
|
|
204
|
+
}
|
|
205
|
+
async function startPollingWatch(rootPath, entry, reason) {
|
|
206
|
+
entry.nativeWatcher?.close();
|
|
207
|
+
entry.nativeWatcher = null;
|
|
208
|
+
entry.mode = 'polling';
|
|
209
|
+
entry.pendingEvents.clear();
|
|
210
|
+
entry.eventGeneration += 1;
|
|
211
|
+
try {
|
|
212
|
+
entry.pollSnapshot = await readDirectorySnapshot(rootPath);
|
|
213
|
+
entry.failure = null;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
entry.failure = error instanceof Error ? error.message : String(error);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
scheduleSharedWatchPoll(rootPath, entry);
|
|
220
|
+
console.warn('[file-watch] Using polling fallback', { rootPath, intervalMs: WATCH_POLL_INTERVAL_MS, reason });
|
|
221
|
+
return true;
|
|
176
222
|
}
|
|
177
223
|
async function acquireSharedWatch(rootPath, client) {
|
|
178
224
|
let entry = sharedWatchRegistry.get(rootPath);
|
|
179
225
|
if (!entry) {
|
|
180
226
|
entry = {
|
|
181
227
|
refs: 0,
|
|
182
|
-
|
|
228
|
+
nativeWatcher: null,
|
|
229
|
+
mode: 'starting',
|
|
183
230
|
failure: null,
|
|
184
231
|
clients: new Set(),
|
|
185
232
|
inFlight: null,
|
|
186
233
|
evictTimer: null,
|
|
234
|
+
pollTimer: null,
|
|
235
|
+
pollSnapshot: null,
|
|
187
236
|
pendingEvents: new Map(),
|
|
188
237
|
statWorkers: 0,
|
|
189
238
|
eventGeneration: 0,
|
|
@@ -201,72 +250,66 @@ async function acquireSharedWatch(rootPath, client) {
|
|
|
201
250
|
entry.needsRescanOnAttach = false;
|
|
202
251
|
client.enqueue({ type: 'rescan-required', path: rootPath, reason: 'reconnected' });
|
|
203
252
|
}
|
|
204
|
-
if (entry.
|
|
205
|
-
|
|
206
|
-
|
|
253
|
+
if (entry.mode === 'polling' && entry.pollSnapshot) {
|
|
254
|
+
scheduleSharedWatchPoll(rootPath, entry, 0);
|
|
255
|
+
return rootPath;
|
|
256
|
+
}
|
|
257
|
+
if (entry.nativeWatcher)
|
|
207
258
|
return rootPath;
|
|
208
259
|
if (!entry.inFlight) {
|
|
209
260
|
const now = Date.now();
|
|
210
261
|
const activeSubscriptions = countActiveSharedWatches();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
rootPath,
|
|
215
|
-
activeSubscriptions,
|
|
216
|
-
registrySize: sharedWatchRegistry.size,
|
|
217
|
-
});
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
const startedAt = Date.now();
|
|
221
|
-
console.info('[file-watch] Starting native subscription', {
|
|
222
|
-
rootPath,
|
|
223
|
-
activeSubscriptions: activeSubscriptions + 1,
|
|
224
|
-
registrySize: sharedWatchRegistry.size,
|
|
225
|
-
});
|
|
226
|
-
entry.inFlight = watcher.subscribe(rootPath, sharedWatchCallback(rootPath, entry), {
|
|
227
|
-
ignore: Array.from(WATCH_IGNORED_NAMES).map((name) => `**/${name}/**`),
|
|
228
|
-
})
|
|
229
|
-
.then((subscription) => {
|
|
230
|
-
const current = sharedWatchRegistry.get(rootPath);
|
|
231
|
-
if (!current || current.refs === 0) {
|
|
232
|
-
// Nobody is watching anymore (client disconnected mid-scan): free
|
|
233
|
-
// the freshly built subscription instead of leaking its DirTree.
|
|
234
|
-
void subscription.unsubscribe().catch(() => undefined);
|
|
235
|
-
return null;
|
|
262
|
+
entry.inFlight = (async () => {
|
|
263
|
+
if (now < watchAdmissionBlockedUntil || activeSubscriptions >= MAX_NATIVE_DIRECTORY_WATCHES) {
|
|
264
|
+
const reason = now < watchAdmissionBlockedUntil ? 'native watcher resource backoff' : 'native watcher process budget reached';
|
|
265
|
+
return startPollingWatch(rootPath, entry, reason);
|
|
236
266
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
try {
|
|
268
|
+
const nativeWatcher = fs.watch(rootPath, { persistent: false }, (_eventType, filename) => {
|
|
269
|
+
enqueueSharedNativeEvent(rootPath, entry, filename);
|
|
270
|
+
});
|
|
271
|
+
entry.nativeWatcher = nativeWatcher;
|
|
272
|
+
entry.mode = 'native';
|
|
273
|
+
entry.failure = null;
|
|
274
|
+
nativeWatcher.on('error', (error) => {
|
|
275
|
+
if (entry.mode !== 'native')
|
|
276
|
+
return;
|
|
277
|
+
const resourceExhausted = isWatchResourceExhaustion(error);
|
|
278
|
+
if (resourceExhausted)
|
|
279
|
+
watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
|
|
280
|
+
if (resourceExhausted)
|
|
281
|
+
void inspectLinuxInotifyUsage().then((diagnostics) => {
|
|
282
|
+
if (diagnostics)
|
|
283
|
+
console.warn('[file-watch] Linux inotify usage after watcher failure', diagnostics);
|
|
284
|
+
}).catch(() => undefined);
|
|
285
|
+
void startPollingWatch(rootPath, entry, error.message || 'native watcher error').then((started) => {
|
|
286
|
+
if (!started) {
|
|
287
|
+
const reason = entry.failure ?? 'watch-unavailable';
|
|
288
|
+
for (const client of [...entry.clients])
|
|
289
|
+
if (!client.closed)
|
|
290
|
+
client.degrade(reason);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
console.info('[file-watch] Native directory watch ready', {
|
|
295
|
+
rootPath,
|
|
296
|
+
activeSubscriptions: activeSubscriptions + 1,
|
|
297
|
+
registrySize: sharedWatchRegistry.size,
|
|
298
|
+
});
|
|
299
|
+
return true;
|
|
267
300
|
}
|
|
268
|
-
|
|
269
|
-
|
|
301
|
+
catch (error) {
|
|
302
|
+
const resourceExhausted = isWatchResourceExhaustion(error);
|
|
303
|
+
if (resourceExhausted)
|
|
304
|
+
watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
|
|
305
|
+
if (resourceExhausted)
|
|
306
|
+
void inspectLinuxInotifyUsage().then((diagnostics) => {
|
|
307
|
+
if (diagnostics)
|
|
308
|
+
console.warn('[file-watch] Linux inotify usage after watcher admission failure', diagnostics);
|
|
309
|
+
}).catch(() => undefined);
|
|
310
|
+
return startPollingWatch(rootPath, entry, error instanceof Error ? error.message : String(error));
|
|
311
|
+
}
|
|
312
|
+
})()
|
|
270
313
|
.finally(() => {
|
|
271
314
|
const current = sharedWatchRegistry.get(rootPath);
|
|
272
315
|
if (current)
|
|
@@ -291,7 +334,11 @@ function releaseSharedWatch(rootPath, client) {
|
|
|
291
334
|
|| entry.statWorkers > 0;
|
|
292
335
|
entry.pendingEvents.clear();
|
|
293
336
|
entry.eventGeneration += 1;
|
|
294
|
-
if (
|
|
337
|
+
if (entry.pollTimer) {
|
|
338
|
+
clearTimeout(entry.pollTimer);
|
|
339
|
+
entry.pollTimer = null;
|
|
340
|
+
}
|
|
341
|
+
if (!entry.nativeWatcher && !entry.inFlight && entry.mode !== 'polling') {
|
|
295
342
|
sharedWatchRegistry.delete(rootPath);
|
|
296
343
|
return;
|
|
297
344
|
}
|
|
@@ -302,8 +349,10 @@ function releaseSharedWatch(rootPath, client) {
|
|
|
302
349
|
if (!current || current.refs > 0)
|
|
303
350
|
return;
|
|
304
351
|
sharedWatchRegistry.delete(rootPath);
|
|
305
|
-
|
|
306
|
-
|
|
352
|
+
if (current.pollTimer)
|
|
353
|
+
clearTimeout(current.pollTimer);
|
|
354
|
+
current.nativeWatcher?.close();
|
|
355
|
+
current.nativeWatcher = null;
|
|
307
356
|
}, SHARED_WATCH_EVICT_DELAY_MS);
|
|
308
357
|
entry.evictTimer.unref?.();
|
|
309
358
|
}
|
|
@@ -1663,13 +1712,17 @@ async function searchWithFallback(rootPath, queryLower, showHidden, excludePatte
|
|
|
1663
1712
|
};
|
|
1664
1713
|
}
|
|
1665
1714
|
function writeSearchEvent(res, type, payload) {
|
|
1666
|
-
res
|
|
1715
|
+
writeResponseChunk(res, `${JSON.stringify({ type, ...payload })}\n`);
|
|
1667
1716
|
}
|
|
1668
1717
|
function createSearchBatchEmitter(res) {
|
|
1669
1718
|
let batch = [];
|
|
1670
1719
|
const flush = () => {
|
|
1671
|
-
if (batch.length === 0
|
|
1720
|
+
if (batch.length === 0)
|
|
1672
1721
|
return;
|
|
1722
|
+
if (!isResponseWritable(res)) {
|
|
1723
|
+
batch = [];
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1673
1726
|
writeSearchEvent(res, 'batch', { entries: batch });
|
|
1674
1727
|
batch = [];
|
|
1675
1728
|
};
|
|
@@ -1807,8 +1860,12 @@ async function streamSearchWithFallback(rootPath, queryLower, showHidden, exclud
|
|
|
1807
1860
|
function createContentBatchEmitter(res) {
|
|
1808
1861
|
let batch = [];
|
|
1809
1862
|
const flush = () => {
|
|
1810
|
-
if (batch.length === 0
|
|
1863
|
+
if (batch.length === 0)
|
|
1811
1864
|
return;
|
|
1865
|
+
if (!isResponseWritable(res)) {
|
|
1866
|
+
batch = [];
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1812
1869
|
writeSearchEvent(res, 'content-batch', { contentEntries: batch });
|
|
1813
1870
|
batch = [];
|
|
1814
1871
|
};
|
|
@@ -2569,8 +2626,17 @@ router.get('/list', async (req, res) => {
|
|
|
2569
2626
|
// deep directory listing; it uses the OS watcher and sends small batched events
|
|
2570
2627
|
// so the client can patch only directories it has already loaded.
|
|
2571
2628
|
router.get('/watch', async (req, res) => {
|
|
2629
|
+
const repeatedRootsParam = req.query.root;
|
|
2572
2630
|
const rootsParam = req.query.roots;
|
|
2573
|
-
const rawRoots = (Array.isArray(
|
|
2631
|
+
const rawRoots = (Array.isArray(repeatedRootsParam)
|
|
2632
|
+
? repeatedRootsParam
|
|
2633
|
+
: typeof repeatedRootsParam === 'string'
|
|
2634
|
+
? [repeatedRootsParam]
|
|
2635
|
+
: Array.isArray(rootsParam)
|
|
2636
|
+
? rootsParam
|
|
2637
|
+
: typeof rootsParam === 'string'
|
|
2638
|
+
? rootsParam.split('|')
|
|
2639
|
+
: [])
|
|
2574
2640
|
.filter((root) => typeof root === 'string');
|
|
2575
2641
|
const validatedRoots = [];
|
|
2576
2642
|
for (const rawRoot of rawRoots) {
|
|
@@ -2586,7 +2652,7 @@ router.get('/watch', async (req, res) => {
|
|
|
2586
2652
|
// Ignore invalid watch roots; the visible tree will still work via manual list requests.
|
|
2587
2653
|
}
|
|
2588
2654
|
}
|
|
2589
|
-
const roots =
|
|
2655
|
+
const roots = normalizeDirectoryWatchRoots(validatedRoots);
|
|
2590
2656
|
if (roots.length === 0) {
|
|
2591
2657
|
res.status(400).json({ error: 'No valid roots to watch' });
|
|
2592
2658
|
return;
|
|
@@ -2598,10 +2664,8 @@ router.get('/watch', async (req, res) => {
|
|
|
2598
2664
|
let pending = [];
|
|
2599
2665
|
let flushTimer = null;
|
|
2600
2666
|
const attachedRoots = new Map();
|
|
2601
|
-
// Register cleanup before the first native
|
|
2602
|
-
//
|
|
2603
|
-
// during that await when the user switches files or roots. Registering this
|
|
2604
|
-
// only after attachRoot() leaked the pending subscription and its reference.
|
|
2667
|
+
// Register cleanup before the first native watch or fallback snapshot starts;
|
|
2668
|
+
// the browser may abort while roots are being validated or attached.
|
|
2605
2669
|
const closeWatchStream = () => {
|
|
2606
2670
|
if (closed)
|
|
2607
2671
|
return;
|
|
@@ -2614,9 +2678,9 @@ router.get('/watch', async (req, res) => {
|
|
|
2614
2678
|
};
|
|
2615
2679
|
res.once('close', closeWatchStream);
|
|
2616
2680
|
const writeEvent = (type, payload) => {
|
|
2617
|
-
if (closed
|
|
2618
|
-
return;
|
|
2619
|
-
res
|
|
2681
|
+
if (closed)
|
|
2682
|
+
return false;
|
|
2683
|
+
return writeResponseChunk(res, `${JSON.stringify({ type, ...payload })}\n`);
|
|
2620
2684
|
};
|
|
2621
2685
|
const flush = () => {
|
|
2622
2686
|
flushTimer = null;
|
|
@@ -2645,7 +2709,7 @@ router.get('/watch', async (req, res) => {
|
|
|
2645
2709
|
requestedRoots: rawRoots.length,
|
|
2646
2710
|
validatedRoots: validatedRoots.length,
|
|
2647
2711
|
subscribedRoots: roots.length,
|
|
2648
|
-
|
|
2712
|
+
deduplicatedRoots: validatedRoots.length - roots.length,
|
|
2649
2713
|
activeSubscriptions: countActiveSharedWatches(),
|
|
2650
2714
|
registrySize: sharedWatchRegistry.size,
|
|
2651
2715
|
});
|
|
@@ -2656,11 +2720,14 @@ router.get('/watch', async (req, res) => {
|
|
|
2656
2720
|
closed: false,
|
|
2657
2721
|
enqueue,
|
|
2658
2722
|
degrade: (reason) => {
|
|
2659
|
-
if (closed)
|
|
2723
|
+
if (closed || !isResponseWritable(res)) {
|
|
2724
|
+
closeWatchStream();
|
|
2660
2725
|
return;
|
|
2726
|
+
}
|
|
2661
2727
|
enqueue({ type: 'rescan-required', path: rootPath, reason });
|
|
2662
2728
|
flush();
|
|
2663
|
-
|
|
2729
|
+
closeWatchStream();
|
|
2730
|
+
if (isResponseWritable(res))
|
|
2664
2731
|
res.end();
|
|
2665
2732
|
},
|
|
2666
2733
|
};
|
|
@@ -2776,14 +2843,15 @@ router.get('/search', async (req, res) => {
|
|
|
2776
2843
|
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', mode: 'content', limited: false });
|
|
2777
2844
|
try {
|
|
2778
2845
|
const result = await streamContentSearchWithRipgrep(resolvedPath, query, showHidden, excludePatterns, contentOptions, controller.signal, res);
|
|
2779
|
-
if (!controller.signal.aborted &&
|
|
2846
|
+
if (!controller.signal.aborted && isResponseWritable(res)) {
|
|
2780
2847
|
writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'rg', mode: 'content' });
|
|
2781
2848
|
logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'content', engine: 'rg' } });
|
|
2782
|
-
res
|
|
2849
|
+
if (isResponseWritable(res))
|
|
2850
|
+
res.end();
|
|
2783
2851
|
}
|
|
2784
2852
|
}
|
|
2785
2853
|
catch (error) {
|
|
2786
|
-
if (controller.signal.aborted || res
|
|
2854
|
+
if (controller.signal.aborted || !isResponseWritable(res)) {
|
|
2787
2855
|
const payload = getErrorPayload(controller.signal.reason ?? error);
|
|
2788
2856
|
logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'content', engine: 'rg' } });
|
|
2789
2857
|
return;
|
|
@@ -2791,7 +2859,8 @@ router.get('/search', async (req, res) => {
|
|
|
2791
2859
|
const message = error instanceof Error ? error.message : 'Content search failed';
|
|
2792
2860
|
logSearch('error', { code: 'CONTENT_SEARCH_UNAVAILABLE', error: message, extra: { mode: 'content', engine: 'rg' } });
|
|
2793
2861
|
writeSearchEvent(res, 'error', { message, code: 'CONTENT_SEARCH_UNAVAILABLE' });
|
|
2794
|
-
res
|
|
2862
|
+
if (isResponseWritable(res))
|
|
2863
|
+
res.end();
|
|
2795
2864
|
}
|
|
2796
2865
|
return;
|
|
2797
2866
|
}
|
|
@@ -2802,24 +2871,26 @@ router.get('/search', async (req, res) => {
|
|
|
2802
2871
|
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', limited: false });
|
|
2803
2872
|
try {
|
|
2804
2873
|
const total = await streamSearchWithRipgrep(resolvedPath, queryLower, showHidden, excludePatterns, controller.signal, res);
|
|
2805
|
-
if (!controller.signal.aborted &&
|
|
2874
|
+
if (!controller.signal.aborted && isResponseWritable(res)) {
|
|
2806
2875
|
writeSearchEvent(res, 'done', { total, truncated: false, limited: false, engine: 'rg' });
|
|
2807
2876
|
logSearch('ok', { count: total, truncated: false, extra: { mode: 'name', engine: 'rg' } });
|
|
2808
|
-
res
|
|
2877
|
+
if (isResponseWritable(res))
|
|
2878
|
+
res.end();
|
|
2809
2879
|
}
|
|
2810
2880
|
}
|
|
2811
2881
|
catch (error) {
|
|
2812
|
-
if (controller.signal.aborted || res
|
|
2882
|
+
if (controller.signal.aborted || !isResponseWritable(res)) {
|
|
2813
2883
|
const payload = getErrorPayload(controller.signal.reason ?? error);
|
|
2814
2884
|
logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'name', engine: 'rg' } });
|
|
2815
2885
|
return;
|
|
2816
2886
|
}
|
|
2817
2887
|
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'fallback', limited: false });
|
|
2818
2888
|
const result = await streamSearchWithFallback(resolvedPath, queryLower, showHidden, excludePatterns, controller.signal, res);
|
|
2819
|
-
if (!controller.signal.aborted &&
|
|
2889
|
+
if (!controller.signal.aborted && isResponseWritable(res)) {
|
|
2820
2890
|
writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'fallback' });
|
|
2821
2891
|
logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'name', engine: 'fallback' } });
|
|
2822
|
-
res
|
|
2892
|
+
if (isResponseWritable(res))
|
|
2893
|
+
res.end();
|
|
2823
2894
|
}
|
|
2824
2895
|
}
|
|
2825
2896
|
return;
|
|
@@ -19,6 +19,7 @@ import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContex
|
|
|
19
19
|
import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
|
|
20
20
|
import { getOnboardingServerUrl } from '../onboardingServer.js';
|
|
21
21
|
import { inspectLinuxInotifyUsage, isWatchResourceExhaustion } from '../utils/fileWatchPolicy.js';
|
|
22
|
+
import { writeResponseChunk } from '../utils/httpResponse.js';
|
|
22
23
|
import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
|
|
23
24
|
import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
|
|
24
25
|
import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, listAgents, } from '../agent/registry.js';
|
|
@@ -3965,7 +3966,7 @@ function shouldRetryShellSpawn(error) {
|
|
|
3965
3966
|
return /posix_spawnp failed|ENOENT|EACCES/i.test(errorMessage);
|
|
3966
3967
|
}
|
|
3967
3968
|
function writeSse(res, payload) {
|
|
3968
|
-
res
|
|
3969
|
+
return writeResponseChunk(res, `data: ${JSON.stringify(payload)}\n\n`);
|
|
3969
3970
|
}
|
|
3970
3971
|
function getTotalClients(sessionId) {
|
|
3971
3972
|
let count = 0;
|
|
@@ -4006,7 +4007,8 @@ function broadcastEvent(sessionId, payload) {
|
|
|
4006
4007
|
if (session) {
|
|
4007
4008
|
for (const [clientId, client] of session.clients.entries()) {
|
|
4008
4009
|
try {
|
|
4009
|
-
writeSse(client, payload)
|
|
4010
|
+
if (!writeSse(client, payload))
|
|
4011
|
+
closeClient(session, sessionId, clientId);
|
|
4010
4012
|
}
|
|
4011
4013
|
catch {
|
|
4012
4014
|
closeClient(session, sessionId, clientId);
|
|
@@ -6514,11 +6516,7 @@ router.get('/:sessionId/stream', async (req, res) => {
|
|
|
6514
6516
|
}, ACTIVE_PROGRAM_POLL_INTERVAL);
|
|
6515
6517
|
}
|
|
6516
6518
|
const heartbeatInterval = setInterval(() => {
|
|
6517
|
-
|
|
6518
|
-
res.write(': heartbeat\n\n');
|
|
6519
|
-
}
|
|
6520
|
-
catch (error) {
|
|
6521
|
-
console.error(`Heartbeat failed for client ${clientId}:`, error);
|
|
6519
|
+
if (!writeResponseChunk(res, ': heartbeat\n\n')) {
|
|
6522
6520
|
clearInterval(heartbeatInterval);
|
|
6523
6521
|
}
|
|
6524
6522
|
}, 15000);
|
|
@@ -10,24 +10,31 @@ export function enqueueLatestWatchEvent(pending, eventPath, eventType, maxPendin
|
|
|
10
10
|
pending.set(eventPath, eventType);
|
|
11
11
|
return 'queued';
|
|
12
12
|
}
|
|
13
|
-
function
|
|
14
|
-
const
|
|
15
|
-
|
|
13
|
+
export function resolveDirectWatchEventPath(rootPath, filename) {
|
|
14
|
+
const resolvedRoot = path.resolve(rootPath);
|
|
15
|
+
const candidate = path.resolve(resolvedRoot, filename.toString());
|
|
16
|
+
return path.dirname(candidate) === resolvedRoot ? candidate : null;
|
|
17
|
+
}
|
|
18
|
+
export function diffWatchSnapshots(previous, next) {
|
|
19
|
+
const changes = [];
|
|
20
|
+
for (const entryPath of previous.keys()) {
|
|
21
|
+
if (!next.has(entryPath))
|
|
22
|
+
changes.push({ type: 'delete', path: entryPath });
|
|
23
|
+
}
|
|
24
|
+
for (const [entryPath, value] of next) {
|
|
25
|
+
const old = previous.get(entryPath);
|
|
26
|
+
if (!old || old.signature !== value.signature) {
|
|
27
|
+
changes.push({ type: old ? 'update' : 'create', path: entryPath, value });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return changes;
|
|
16
31
|
}
|
|
17
32
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* another overlapping native watcher.
|
|
33
|
+
* Non-recursive directory watchers need every visible directory, including
|
|
34
|
+
* nested ones. Normalize and deduplicate without collapsing descendants.
|
|
21
35
|
*/
|
|
22
|
-
export function
|
|
23
|
-
|
|
24
|
-
uniqueRoots.sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b));
|
|
25
|
-
const minimal = [];
|
|
26
|
-
for (const root of uniqueRoots) {
|
|
27
|
-
if (!minimal.some((parent) => isSameOrDescendant(parent, root)))
|
|
28
|
-
minimal.push(root);
|
|
29
|
-
}
|
|
30
|
-
return minimal;
|
|
36
|
+
export function normalizeDirectoryWatchRoots(roots) {
|
|
37
|
+
return [...new Set(roots.map((root) => path.resolve(root)))].sort((a, b) => a.localeCompare(b));
|
|
31
38
|
}
|
|
32
39
|
export function getWatchErrorCode(error) {
|
|
33
40
|
if (!error || typeof error !== 'object')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function isResponseWritable(response) {
|
|
2
|
+
return !response.destroyed && !response.writableEnded && !response.writableFinished;
|
|
3
|
+
}
|
|
4
|
+
export function writeResponseChunk(response, chunk) {
|
|
5
|
+
if (!isResponseWritable(response))
|
|
6
|
+
return false;
|
|
7
|
+
try {
|
|
8
|
+
response.write(chunk);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|