termdock 1.4.150 → 1.4.152
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-uLSarP4D.js → ModelPreview-eQHIXZSi.js} +1 -1
- package/dist/client/assets/{abnfDiagram-VRR7QNED-LRvFux5X.js → abnfDiagram-VRR7QNED-DVpP-Ck_.js} +1 -1
- package/dist/client/assets/{arc-DNw2kfZ0.js → arc-BUWNItqM.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-DjZ3s7fQ.js → architectureDiagram-ZJ3FMSHR-cE0XflG9.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-Dtd_IKpz.js → blockDiagram-677ZJIJ3-CRUJfqnu.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-mzZ4dVs4.js → c4Diagram-LMCZKHZV-CfYifxuE.js} +1 -1
- package/dist/client/assets/channel-DNGAEw-f.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-_SFJXlb5.js → chunk-2Q5K7J3B-BdaPjMEy.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-r_v0waX5.js → chunk-32BRIVSS-D6Eb-pQ6.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-CU1_Nmy8.js → chunk-5VM5RSS4-C-oAudIE.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-DsPxrvQl.js → chunk-EX3LRPZG-BOHtjSTK.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-CRqyMcg1.js → chunk-JWPE2WC7-DG4ba0nk.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-BxJ6_ubT.js → chunk-MOJQB5TN-BlZyZyeN.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DzqJd87Z.js → chunk-RYQCIY6F-CaucB3jb.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-CjDkrTUB.js → chunk-V7JOEXUC-1pz-6Ccd.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-b6vd8xBy.js → chunk-VR4S4FIN-Cxjlb154.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-V9HlNoo1.js → chunk-XXDRQBXY-B1WP3198.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CwvsZeN6.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CwvsZeN6.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-CG28xkJk.js → cose-bilkent-JH36ORCC-VJN6hEws.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-DB7EZ1-e.js → cynefin-VYW2F7L2-B6cBvU5_.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-CBnLantQ.js → cynefinDiagram-TSTJHNR4-DjZPoBPi.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-DIX_xZyT.js → dagre-VKFMJZFB-Gn3YE7F6.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-DevjfCup.js → diagram-FQU43EPY-D8A13xZb.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-CsmEsXKw.js → diagram-G47NLZAW-71ML-ld7.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-C3s1JiKg.js → diagram-NH7WQ7WH-B7eGBWWg.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-DMuuXoRh.js → diagram-OA4YK3LP-CbgiA51E.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-BUiXOAtw.js → diagram-WEI45ONY-qsJy_J2A.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-BRjVSbcV.js → ebnfDiagram-CCIWWBDH-DXRvMbEO.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-C_WH08Zw.js → erDiagram-Q63AITRT-CObkXi3v.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-DLIcFn5v.js → flowDiagram-23GEKE2U-CL5c6WSh.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-BaI9-Mv5.js → ganttDiagram-NO4QXBWP-r3N66qb4.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-IYSJpCp8.js → gitGraphDiagram-IHSO6WYX-D-ALPOPq.js} +1 -1
- package/dist/client/assets/index-BQKH9KWh.css +1 -0
- package/dist/client/assets/index-CHsO9fJ1.js +631 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-D2zJK0W5.js → infoDiagram-FWYZ7A6U-TK7ImxMu.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Bn3NuQ-o.js → ishikawaDiagram-FXEZZL3T-DC-sjv04.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-C5J5U8wH.js → journeyDiagram-5HDEW3XC-IOU7vr4d.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-DSQy9Zpl.js → kanban-definition-HUTT4EX6-BaXN_VRb.js} +1 -1
- package/dist/client/assets/{linear-DwEWVHBG.js → linear-BDDuGH46.js} +1 -1
- package/dist/client/assets/{mermaid.core-Cq6MlkF6.js → mermaid.core-BVBeoUSn.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-CL652EWv.js → mindmap-definition-LN4V7U3C-B-aOT1JJ.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-Csq-yPnl.js → pegDiagram-2B236MQR-BA0bA9lE.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-Nw4ezF_J.js → pieDiagram-ENE6RG2P-CIO8t_fq.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DoCDVpbT.js → quadrantDiagram-ABIIQ3AL-DXfNHoC-.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-q-BqkN1P.js → railroadDiagram-RFXS5EU6-DEVQtNvi.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-Cnlu8uA8.js → requirementDiagram-TGXJPOKE-x2jq4Aes.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CyqCLIkV.js → sankeyDiagram-HTMAVEWB-qfEuuNtx.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-PAF-solb.js → sequenceDiagram-DBY2YBRQ-TcEwE9X8.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-DqA34cSw.js → sizeCapture-X5ZJPWSS-BoW8XorN.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-CfHdGfI2.js → stateDiagram-2N3HPSRC-CKUYRngR.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BLo4197v.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-VpPTM-Jz.js → swimlanes-5IMT3BWC-ke9Inr5n.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-BatBCy_S.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6--JyCyiUv.js → timeline-definition-FHXFAJF6-C212ZNW1.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-B14FUZwp.js → vennDiagram-L72KCM5P-CsMo_a1c.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-CkCxrnnh.js → wardleyDiagram-EHGQE667-C2QO5XVB.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-C4w2gntS.js → xychartDiagram-FW5EYKEG-DxQc72uf.js} +1 -1
- package/dist/client/index.html +8 -4
- package/dist/client/sw.js +1 -1
- package/dist/server/routes/filesystem.js +216 -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-mKVBJtmM.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CAMnLD8c.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CAMnLD8c.js +0 -1
- package/dist/client/assets/index-CFpYppDM.js +0 -631
- package/dist/client/assets/index-DZNvUpMj.css +0 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DcUmtjE2.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-pmYcUfvR.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,124 @@ 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
|
+
broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'polling-fallback' });
|
|
220
|
+
scheduleSharedWatchPoll(rootPath, entry);
|
|
221
|
+
console.warn('[file-watch] Using polling fallback', { rootPath, intervalMs: WATCH_POLL_INTERVAL_MS, reason });
|
|
222
|
+
return true;
|
|
176
223
|
}
|
|
177
224
|
async function acquireSharedWatch(rootPath, client) {
|
|
178
225
|
let entry = sharedWatchRegistry.get(rootPath);
|
|
179
226
|
if (!entry) {
|
|
180
227
|
entry = {
|
|
181
228
|
refs: 0,
|
|
182
|
-
|
|
229
|
+
nativeWatcher: null,
|
|
230
|
+
mode: 'starting',
|
|
183
231
|
failure: null,
|
|
184
232
|
clients: new Set(),
|
|
185
233
|
inFlight: null,
|
|
186
234
|
evictTimer: null,
|
|
235
|
+
pollTimer: null,
|
|
236
|
+
pollSnapshot: null,
|
|
187
237
|
pendingEvents: new Map(),
|
|
188
238
|
statWorkers: 0,
|
|
189
239
|
eventGeneration: 0,
|
|
@@ -201,72 +251,66 @@ async function acquireSharedWatch(rootPath, client) {
|
|
|
201
251
|
entry.needsRescanOnAttach = false;
|
|
202
252
|
client.enqueue({ type: 'rescan-required', path: rootPath, reason: 'reconnected' });
|
|
203
253
|
}
|
|
204
|
-
if (entry.
|
|
205
|
-
|
|
206
|
-
|
|
254
|
+
if (entry.mode === 'polling' && entry.pollSnapshot) {
|
|
255
|
+
scheduleSharedWatchPoll(rootPath, entry, 0);
|
|
256
|
+
return rootPath;
|
|
257
|
+
}
|
|
258
|
+
if (entry.nativeWatcher)
|
|
207
259
|
return rootPath;
|
|
208
260
|
if (!entry.inFlight) {
|
|
209
261
|
const now = Date.now();
|
|
210
262
|
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;
|
|
263
|
+
entry.inFlight = (async () => {
|
|
264
|
+
if (now < watchAdmissionBlockedUntil || activeSubscriptions >= MAX_NATIVE_DIRECTORY_WATCHES) {
|
|
265
|
+
const reason = now < watchAdmissionBlockedUntil ? 'native watcher resource backoff' : 'native watcher process budget reached';
|
|
266
|
+
return startPollingWatch(rootPath, entry, reason);
|
|
236
267
|
}
|
|
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
|
-
|
|
268
|
+
try {
|
|
269
|
+
const nativeWatcher = fs.watch(rootPath, { persistent: false }, (_eventType, filename) => {
|
|
270
|
+
enqueueSharedNativeEvent(rootPath, entry, filename);
|
|
271
|
+
});
|
|
272
|
+
entry.nativeWatcher = nativeWatcher;
|
|
273
|
+
entry.mode = 'native';
|
|
274
|
+
entry.failure = null;
|
|
275
|
+
nativeWatcher.on('error', (error) => {
|
|
276
|
+
if (entry.mode !== 'native')
|
|
277
|
+
return;
|
|
278
|
+
const resourceExhausted = isWatchResourceExhaustion(error);
|
|
279
|
+
if (resourceExhausted)
|
|
280
|
+
watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
|
|
281
|
+
if (resourceExhausted)
|
|
282
|
+
void inspectLinuxInotifyUsage().then((diagnostics) => {
|
|
283
|
+
if (diagnostics)
|
|
284
|
+
console.warn('[file-watch] Linux inotify usage after watcher failure', diagnostics);
|
|
285
|
+
}).catch(() => undefined);
|
|
286
|
+
void startPollingWatch(rootPath, entry, error.message || 'native watcher error').then((started) => {
|
|
287
|
+
if (!started) {
|
|
288
|
+
const reason = entry.failure ?? 'watch-unavailable';
|
|
289
|
+
for (const client of [...entry.clients])
|
|
290
|
+
if (!client.closed)
|
|
291
|
+
client.degrade(reason);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
console.info('[file-watch] Native directory watch ready', {
|
|
296
|
+
rootPath,
|
|
297
|
+
activeSubscriptions: activeSubscriptions + 1,
|
|
298
|
+
registrySize: sharedWatchRegistry.size,
|
|
299
|
+
});
|
|
300
|
+
return true;
|
|
267
301
|
}
|
|
268
|
-
|
|
269
|
-
|
|
302
|
+
catch (error) {
|
|
303
|
+
const resourceExhausted = isWatchResourceExhaustion(error);
|
|
304
|
+
if (resourceExhausted)
|
|
305
|
+
watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
|
|
306
|
+
if (resourceExhausted)
|
|
307
|
+
void inspectLinuxInotifyUsage().then((diagnostics) => {
|
|
308
|
+
if (diagnostics)
|
|
309
|
+
console.warn('[file-watch] Linux inotify usage after watcher admission failure', diagnostics);
|
|
310
|
+
}).catch(() => undefined);
|
|
311
|
+
return startPollingWatch(rootPath, entry, error instanceof Error ? error.message : String(error));
|
|
312
|
+
}
|
|
313
|
+
})()
|
|
270
314
|
.finally(() => {
|
|
271
315
|
const current = sharedWatchRegistry.get(rootPath);
|
|
272
316
|
if (current)
|
|
@@ -291,7 +335,11 @@ function releaseSharedWatch(rootPath, client) {
|
|
|
291
335
|
|| entry.statWorkers > 0;
|
|
292
336
|
entry.pendingEvents.clear();
|
|
293
337
|
entry.eventGeneration += 1;
|
|
294
|
-
if (
|
|
338
|
+
if (entry.pollTimer) {
|
|
339
|
+
clearTimeout(entry.pollTimer);
|
|
340
|
+
entry.pollTimer = null;
|
|
341
|
+
}
|
|
342
|
+
if (!entry.nativeWatcher && !entry.inFlight) {
|
|
295
343
|
sharedWatchRegistry.delete(rootPath);
|
|
296
344
|
return;
|
|
297
345
|
}
|
|
@@ -302,8 +350,10 @@ function releaseSharedWatch(rootPath, client) {
|
|
|
302
350
|
if (!current || current.refs > 0)
|
|
303
351
|
return;
|
|
304
352
|
sharedWatchRegistry.delete(rootPath);
|
|
305
|
-
|
|
306
|
-
|
|
353
|
+
if (current.pollTimer)
|
|
354
|
+
clearTimeout(current.pollTimer);
|
|
355
|
+
current.nativeWatcher?.close();
|
|
356
|
+
current.nativeWatcher = null;
|
|
307
357
|
}, SHARED_WATCH_EVICT_DELAY_MS);
|
|
308
358
|
entry.evictTimer.unref?.();
|
|
309
359
|
}
|
|
@@ -1663,13 +1713,17 @@ async function searchWithFallback(rootPath, queryLower, showHidden, excludePatte
|
|
|
1663
1713
|
};
|
|
1664
1714
|
}
|
|
1665
1715
|
function writeSearchEvent(res, type, payload) {
|
|
1666
|
-
res
|
|
1716
|
+
writeResponseChunk(res, `${JSON.stringify({ type, ...payload })}\n`);
|
|
1667
1717
|
}
|
|
1668
1718
|
function createSearchBatchEmitter(res) {
|
|
1669
1719
|
let batch = [];
|
|
1670
1720
|
const flush = () => {
|
|
1671
|
-
if (batch.length === 0
|
|
1721
|
+
if (batch.length === 0)
|
|
1672
1722
|
return;
|
|
1723
|
+
if (!isResponseWritable(res)) {
|
|
1724
|
+
batch = [];
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1673
1727
|
writeSearchEvent(res, 'batch', { entries: batch });
|
|
1674
1728
|
batch = [];
|
|
1675
1729
|
};
|
|
@@ -1807,8 +1861,12 @@ async function streamSearchWithFallback(rootPath, queryLower, showHidden, exclud
|
|
|
1807
1861
|
function createContentBatchEmitter(res) {
|
|
1808
1862
|
let batch = [];
|
|
1809
1863
|
const flush = () => {
|
|
1810
|
-
if (batch.length === 0
|
|
1864
|
+
if (batch.length === 0)
|
|
1811
1865
|
return;
|
|
1866
|
+
if (!isResponseWritable(res)) {
|
|
1867
|
+
batch = [];
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1812
1870
|
writeSearchEvent(res, 'content-batch', { contentEntries: batch });
|
|
1813
1871
|
batch = [];
|
|
1814
1872
|
};
|
|
@@ -2569,8 +2627,17 @@ router.get('/list', async (req, res) => {
|
|
|
2569
2627
|
// deep directory listing; it uses the OS watcher and sends small batched events
|
|
2570
2628
|
// so the client can patch only directories it has already loaded.
|
|
2571
2629
|
router.get('/watch', async (req, res) => {
|
|
2630
|
+
const repeatedRootsParam = req.query.root;
|
|
2572
2631
|
const rootsParam = req.query.roots;
|
|
2573
|
-
const rawRoots = (Array.isArray(
|
|
2632
|
+
const rawRoots = (Array.isArray(repeatedRootsParam)
|
|
2633
|
+
? repeatedRootsParam
|
|
2634
|
+
: typeof repeatedRootsParam === 'string'
|
|
2635
|
+
? [repeatedRootsParam]
|
|
2636
|
+
: Array.isArray(rootsParam)
|
|
2637
|
+
? rootsParam
|
|
2638
|
+
: typeof rootsParam === 'string'
|
|
2639
|
+
? rootsParam.split('|')
|
|
2640
|
+
: [])
|
|
2574
2641
|
.filter((root) => typeof root === 'string');
|
|
2575
2642
|
const validatedRoots = [];
|
|
2576
2643
|
for (const rawRoot of rawRoots) {
|
|
@@ -2586,7 +2653,7 @@ router.get('/watch', async (req, res) => {
|
|
|
2586
2653
|
// Ignore invalid watch roots; the visible tree will still work via manual list requests.
|
|
2587
2654
|
}
|
|
2588
2655
|
}
|
|
2589
|
-
const roots =
|
|
2656
|
+
const roots = normalizeDirectoryWatchRoots(validatedRoots);
|
|
2590
2657
|
if (roots.length === 0) {
|
|
2591
2658
|
res.status(400).json({ error: 'No valid roots to watch' });
|
|
2592
2659
|
return;
|
|
@@ -2598,10 +2665,8 @@ router.get('/watch', async (req, res) => {
|
|
|
2598
2665
|
let pending = [];
|
|
2599
2666
|
let flushTimer = null;
|
|
2600
2667
|
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.
|
|
2668
|
+
// Register cleanup before the first native watch or fallback snapshot starts;
|
|
2669
|
+
// the browser may abort while roots are being validated or attached.
|
|
2605
2670
|
const closeWatchStream = () => {
|
|
2606
2671
|
if (closed)
|
|
2607
2672
|
return;
|
|
@@ -2614,9 +2679,9 @@ router.get('/watch', async (req, res) => {
|
|
|
2614
2679
|
};
|
|
2615
2680
|
res.once('close', closeWatchStream);
|
|
2616
2681
|
const writeEvent = (type, payload) => {
|
|
2617
|
-
if (closed
|
|
2618
|
-
return;
|
|
2619
|
-
res
|
|
2682
|
+
if (closed)
|
|
2683
|
+
return false;
|
|
2684
|
+
return writeResponseChunk(res, `${JSON.stringify({ type, ...payload })}\n`);
|
|
2620
2685
|
};
|
|
2621
2686
|
const flush = () => {
|
|
2622
2687
|
flushTimer = null;
|
|
@@ -2645,7 +2710,7 @@ router.get('/watch', async (req, res) => {
|
|
|
2645
2710
|
requestedRoots: rawRoots.length,
|
|
2646
2711
|
validatedRoots: validatedRoots.length,
|
|
2647
2712
|
subscribedRoots: roots.length,
|
|
2648
|
-
|
|
2713
|
+
deduplicatedRoots: validatedRoots.length - roots.length,
|
|
2649
2714
|
activeSubscriptions: countActiveSharedWatches(),
|
|
2650
2715
|
registrySize: sharedWatchRegistry.size,
|
|
2651
2716
|
});
|
|
@@ -2656,11 +2721,14 @@ router.get('/watch', async (req, res) => {
|
|
|
2656
2721
|
closed: false,
|
|
2657
2722
|
enqueue,
|
|
2658
2723
|
degrade: (reason) => {
|
|
2659
|
-
if (closed)
|
|
2724
|
+
if (closed || !isResponseWritable(res)) {
|
|
2725
|
+
closeWatchStream();
|
|
2660
2726
|
return;
|
|
2727
|
+
}
|
|
2661
2728
|
enqueue({ type: 'rescan-required', path: rootPath, reason });
|
|
2662
2729
|
flush();
|
|
2663
|
-
|
|
2730
|
+
closeWatchStream();
|
|
2731
|
+
if (isResponseWritable(res))
|
|
2664
2732
|
res.end();
|
|
2665
2733
|
},
|
|
2666
2734
|
};
|
|
@@ -2776,14 +2844,15 @@ router.get('/search', async (req, res) => {
|
|
|
2776
2844
|
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', mode: 'content', limited: false });
|
|
2777
2845
|
try {
|
|
2778
2846
|
const result = await streamContentSearchWithRipgrep(resolvedPath, query, showHidden, excludePatterns, contentOptions, controller.signal, res);
|
|
2779
|
-
if (!controller.signal.aborted &&
|
|
2847
|
+
if (!controller.signal.aborted && isResponseWritable(res)) {
|
|
2780
2848
|
writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'rg', mode: 'content' });
|
|
2781
2849
|
logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'content', engine: 'rg' } });
|
|
2782
|
-
res
|
|
2850
|
+
if (isResponseWritable(res))
|
|
2851
|
+
res.end();
|
|
2783
2852
|
}
|
|
2784
2853
|
}
|
|
2785
2854
|
catch (error) {
|
|
2786
|
-
if (controller.signal.aborted || res
|
|
2855
|
+
if (controller.signal.aborted || !isResponseWritable(res)) {
|
|
2787
2856
|
const payload = getErrorPayload(controller.signal.reason ?? error);
|
|
2788
2857
|
logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'content', engine: 'rg' } });
|
|
2789
2858
|
return;
|
|
@@ -2791,7 +2860,8 @@ router.get('/search', async (req, res) => {
|
|
|
2791
2860
|
const message = error instanceof Error ? error.message : 'Content search failed';
|
|
2792
2861
|
logSearch('error', { code: 'CONTENT_SEARCH_UNAVAILABLE', error: message, extra: { mode: 'content', engine: 'rg' } });
|
|
2793
2862
|
writeSearchEvent(res, 'error', { message, code: 'CONTENT_SEARCH_UNAVAILABLE' });
|
|
2794
|
-
res
|
|
2863
|
+
if (isResponseWritable(res))
|
|
2864
|
+
res.end();
|
|
2795
2865
|
}
|
|
2796
2866
|
return;
|
|
2797
2867
|
}
|
|
@@ -2802,24 +2872,26 @@ router.get('/search', async (req, res) => {
|
|
|
2802
2872
|
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'rg', limited: false });
|
|
2803
2873
|
try {
|
|
2804
2874
|
const total = await streamSearchWithRipgrep(resolvedPath, queryLower, showHidden, excludePatterns, controller.signal, res);
|
|
2805
|
-
if (!controller.signal.aborted &&
|
|
2875
|
+
if (!controller.signal.aborted && isResponseWritable(res)) {
|
|
2806
2876
|
writeSearchEvent(res, 'done', { total, truncated: false, limited: false, engine: 'rg' });
|
|
2807
2877
|
logSearch('ok', { count: total, truncated: false, extra: { mode: 'name', engine: 'rg' } });
|
|
2808
|
-
res
|
|
2878
|
+
if (isResponseWritable(res))
|
|
2879
|
+
res.end();
|
|
2809
2880
|
}
|
|
2810
2881
|
}
|
|
2811
2882
|
catch (error) {
|
|
2812
|
-
if (controller.signal.aborted || res
|
|
2883
|
+
if (controller.signal.aborted || !isResponseWritable(res)) {
|
|
2813
2884
|
const payload = getErrorPayload(controller.signal.reason ?? error);
|
|
2814
2885
|
logSearch('error', { code: payload.code, error: payload.error, extra: { mode: 'name', engine: 'rg' } });
|
|
2815
2886
|
return;
|
|
2816
2887
|
}
|
|
2817
2888
|
writeSearchEvent(res, 'meta', { path: resolvedPath, query, engine: 'fallback', limited: false });
|
|
2818
2889
|
const result = await streamSearchWithFallback(resolvedPath, queryLower, showHidden, excludePatterns, controller.signal, res);
|
|
2819
|
-
if (!controller.signal.aborted &&
|
|
2890
|
+
if (!controller.signal.aborted && isResponseWritable(res)) {
|
|
2820
2891
|
writeSearchEvent(res, 'done', { total: result.total, truncated: result.limited, limited: result.limited, engine: 'fallback' });
|
|
2821
2892
|
logSearch('ok', { count: result.total, truncated: result.limited, extra: { mode: 'name', engine: 'fallback' } });
|
|
2822
|
-
res
|
|
2893
|
+
if (isResponseWritable(res))
|
|
2894
|
+
res.end();
|
|
2823
2895
|
}
|
|
2824
2896
|
}
|
|
2825
2897
|
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
|
+
}
|