termdock 1.4.121 → 1.4.123
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-DsQchw3K.js → ModelPreview-DGvy0ViD.js} +1 -1
- package/dist/client/assets/{abnfDiagram-VRR7QNED-CFyMTXyL.js → abnfDiagram-VRR7QNED-BRUTM7Ij.js} +1 -1
- package/dist/client/assets/{arc-DWhW0i2g.js → arc-DeKie3_7.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-qs8HU17x.js → architectureDiagram-ZJ3FMSHR-CAorViVK.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-Ctu5j2R5.js → blockDiagram-677ZJIJ3-DHq1C73O.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-BZeokwNX.js → c4Diagram-LMCZKHZV-DluWWKuh.js} +1 -1
- package/dist/client/assets/channel-DXiU4T4d.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-3kNs1Ku0.js → chunk-2Q5K7J3B-BPSQsAYp.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-rAdLwMim.js → chunk-32BRIVSS-g93sHdJF.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-DiCSy2sF.js → chunk-5VM5RSS4-CvtNtBa-.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-CqgAS6mE.js → chunk-EX3LRPZG-DWnR2mqn.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-CfP2DkTP.js → chunk-JWPE2WC7-Dzbpe3Xo.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-SOwz_D-F.js → chunk-MOJQB5TN-Du2Jw6Ga.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-BtWKkfPB.js → chunk-RYQCIY6F-DIqqwReI.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-SbCtu541.js → chunk-V7JOEXUC-BDK2KnFE.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-LwUGLXML.js → chunk-VR4S4FIN-BncB3kMk.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-sebs-LeT.js → chunk-XXDRQBXY-CRx51cPn.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CreSS-cc.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CreSS-cc.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-B0MKXBxr.js → cose-bilkent-JH36ORCC-C4eOHhIl.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-CX1iiD9m.js → cynefin-VYW2F7L2-BZuh5fJN.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BFWs1VnK.js → cynefinDiagram-TSTJHNR4-BdyBy9fD.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-BtfTTYnV.js → dagre-VKFMJZFB-tlpcqDkv.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-CtH95Yjx.js → diagram-FQU43EPY-DmryDLBx.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-xJ1CuJKa.js → diagram-G47NLZAW-DyaVVj3y.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-CtTo9GYq.js → diagram-NH7WQ7WH-DfwqogJh.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-D5v1FAZ9.js → diagram-OA4YK3LP-BHGytgzq.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-BAxQcj8H.js → diagram-WEI45ONY-BW70jDhO.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-DZ2dIXxg.js → ebnfDiagram-CCIWWBDH-BkvYsVdX.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-Bl9DOZNu.js → erDiagram-Q63AITRT-B-qynR5w.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-DdfDxdBo.js → flowDiagram-23GEKE2U-C0uDIjVs.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-DxnT1ZVH.js → ganttDiagram-NO4QXBWP-jHMmeGdm.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BY_SrTTK.js → gitGraphDiagram-IHSO6WYX-Cyp33RGh.js} +1 -1
- package/dist/client/assets/index-BCdd_adt.css +1 -0
- package/dist/client/assets/index-DPxgzRe_.js +457 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-DFAXdG0t.js → infoDiagram-FWYZ7A6U-BX1T5ZRV.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Cza6z98S.js → ishikawaDiagram-FXEZZL3T-BehchXvq.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-6Mq1Yp_I.js → journeyDiagram-5HDEW3XC-Bdy1ed1k.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-CvTaNPB1.js → kanban-definition-HUTT4EX6-C7n-XF4G.js} +1 -1
- package/dist/client/assets/{linear-BxvamTpC.js → linear-Cj0QkFq-.js} +1 -1
- package/dist/client/assets/{mermaid.core-CvGjmRiA.js → mermaid.core-kFgOOXNm.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-P5s5VwkA.js → mindmap-definition-LN4V7U3C-X3xTk9oE.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-CcgERS4b.js → pegDiagram-2B236MQR-DHr3LWfT.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-lu_uZMjY.js → pieDiagram-ENE6RG2P-Cgk4qJXy.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-CixHZa8E.js → quadrantDiagram-ABIIQ3AL-Db452gS6.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-Bs4rI4Co.js → railroadDiagram-RFXS5EU6-Ch79_eAD.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-COMua6-x.js → requirementDiagram-TGXJPOKE-WeVLc483.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CHjivcTz.js → sankeyDiagram-HTMAVEWB-2ClPBKFi.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-kP1sT3bG.js → sequenceDiagram-DBY2YBRQ-DMOUB-GA.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-CSyesB4l.js → sizeCapture-X5ZJPWSS-QASuTy1-.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-Dv9tX_RU.js → stateDiagram-2N3HPSRC-BNeDpvX-.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DUPNitKK.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-CdvxFNFu.js → swimlanes-5IMT3BWC-DvuVVUtW.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-BhOFXgln.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-BT-dDyTO.js → timeline-definition-FHXFAJF6-DuTo7eYA.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-CbffJ-gd.js → vennDiagram-L72KCM5P-Do7RZnUg.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-Cu5Yvx2k.js → wardleyDiagram-EHGQE667-BSvQIlfU.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-NVbSc9iU.js → xychartDiagram-FW5EYKEG-BONZO9-F.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/routes/filesystem.js +233 -59
- package/dist/server/routes/terminal.js +29 -3
- package/dist/server/utils/fileWatchPolicy.js +136 -0
- package/dist/server/utils/settings.js +29 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
- package/dist/client/assets/channel-CgVUhEh9.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-A60TxAHA.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-A60TxAHA.js +0 -1
- package/dist/client/assets/index-UhZwBu7l.css +0 -1
- package/dist/client/assets/index-rRxD1BhF.js +0 -457
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DmZzXEeI.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-CfgiHnJ1.js +0 -8
|
@@ -18,6 +18,7 @@ import { deleteFilesystemFile } from '../utils/deleteFilesystemFile.js';
|
|
|
18
18
|
import { inspectKicadBoardPoint } from '../utils/kicadBoardInspection.js';
|
|
19
19
|
import { EdaPreviewCache, requestAcceptsEtag } from '../utils/edaPreviewCache.js';
|
|
20
20
|
import { convertHeicPreview, HEIC_PREVIEW_MIME_TYPES, isHeicPreviewPath } from '../utils/heicPreview.js';
|
|
21
|
+
import { getWatchErrorCode, enqueueLatestWatchEvent, inspectLinuxInotifyUsage, isWatchResourceExhaustion, minimizeRecursiveWatchRoots, WATCH_IGNORED_NAMES, WATCH_RESOURCE_BACKOFF_MS, } from '../utils/fileWatchPolicy.js';
|
|
21
22
|
const router = Router();
|
|
22
23
|
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
|
|
23
24
|
const MAX_IMAGE_PREVIEW_SIZE = 20 * 1024 * 1024; // 20MB
|
|
@@ -59,7 +60,77 @@ const FS_IO_LOG_NAME = 'fs-io.log';
|
|
|
59
60
|
const activeDiffSlots = new Map();
|
|
60
61
|
const activeIoSlots = new Map();
|
|
61
62
|
const sharedWatchRegistry = new Map();
|
|
62
|
-
const SHARED_WATCH_EVICT_DELAY_MS =
|
|
63
|
+
const SHARED_WATCH_EVICT_DELAY_MS = 5_000;
|
|
64
|
+
const WATCH_STAT_CONCURRENCY = 8;
|
|
65
|
+
let watchAdmissionBlockedUntil = 0;
|
|
66
|
+
function countActiveSharedWatches() {
|
|
67
|
+
let count = 0;
|
|
68
|
+
for (const entry of sharedWatchRegistry.values()) {
|
|
69
|
+
if (entry.subscription || entry.inFlight)
|
|
70
|
+
count += 1;
|
|
71
|
+
}
|
|
72
|
+
return count;
|
|
73
|
+
}
|
|
74
|
+
function broadcastSharedWatchEvent(entry, event) {
|
|
75
|
+
for (const client of entry.clients) {
|
|
76
|
+
if (!client.closed)
|
|
77
|
+
client.enqueue(event);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function drainSharedWatchEvents(entry) {
|
|
81
|
+
if (entry.failure || entry.clients.size === 0)
|
|
82
|
+
return;
|
|
83
|
+
while (entry.statWorkers < WATCH_STAT_CONCURRENCY && entry.pendingEvents.size > 0) {
|
|
84
|
+
const next = entry.pendingEvents.entries().next().value;
|
|
85
|
+
if (!next)
|
|
86
|
+
return;
|
|
87
|
+
const [changedPath, eventType] = next;
|
|
88
|
+
entry.pendingEvents.delete(changedPath);
|
|
89
|
+
if (eventType === 'delete') {
|
|
90
|
+
broadcastSharedWatchEvent(entry, { type: 'deleted', path: changedPath });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const generation = entry.eventGeneration;
|
|
94
|
+
entry.statWorkers += 1;
|
|
95
|
+
void fs.promises.lstat(changedPath)
|
|
96
|
+
.then((stat) => {
|
|
97
|
+
if (entry.failure || generation !== entry.eventGeneration)
|
|
98
|
+
return;
|
|
99
|
+
broadcastSharedWatchEvent(entry, {
|
|
100
|
+
type: eventType === 'create' ? 'created' : 'updated',
|
|
101
|
+
path: changedPath,
|
|
102
|
+
entry: toFileEntry(changedPath, stat),
|
|
103
|
+
});
|
|
104
|
+
})
|
|
105
|
+
.catch(() => {
|
|
106
|
+
if (entry.failure || generation !== entry.eventGeneration)
|
|
107
|
+
return;
|
|
108
|
+
broadcastSharedWatchEvent(entry, { type: 'deleted', path: changedPath });
|
|
109
|
+
})
|
|
110
|
+
.finally(() => {
|
|
111
|
+
entry.statWorkers -= 1;
|
|
112
|
+
drainSharedWatchEvents(entry);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function enqueueSharedNativeEvents(rootPath, entry, events) {
|
|
117
|
+
if (entry.clients.size === 0)
|
|
118
|
+
return;
|
|
119
|
+
for (const event of events) {
|
|
120
|
+
const changedPath = path.resolve(event.path);
|
|
121
|
+
if (!isPathInside(rootPath, changedPath) || isIgnoredWatchPath(rootPath, changedPath))
|
|
122
|
+
continue;
|
|
123
|
+
if (enqueueLatestWatchEvent(entry.pendingEvents, changedPath, event.type, WATCH_EVENT_STORM_LIMIT) === 'overflow') {
|
|
124
|
+
entry.pendingEvents.clear();
|
|
125
|
+
entry.eventGeneration += 1;
|
|
126
|
+
broadcastSharedWatchEvent(entry, { type: 'rescan-required', path: rootPath, reason: 'event-storm' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// The latest event for one path wins. This turns save bursts and atomic
|
|
130
|
+
// editor renames into one stat operation instead of one operation/event.
|
|
131
|
+
}
|
|
132
|
+
drainSharedWatchEvents(entry);
|
|
133
|
+
}
|
|
63
134
|
function sharedWatchCallback(rootPath, entry) {
|
|
64
135
|
return (error, events) => {
|
|
65
136
|
if (entry.failure)
|
|
@@ -67,42 +138,35 @@ function sharedWatchCallback(rootPath, entry) {
|
|
|
67
138
|
if (error) {
|
|
68
139
|
entry.failure = error.message || 'watch-error';
|
|
69
140
|
const reason = entry.failure;
|
|
70
|
-
|
|
141
|
+
entry.pendingEvents.clear();
|
|
142
|
+
entry.eventGeneration += 1;
|
|
143
|
+
const resourceExhausted = isWatchResourceExhaustion(error);
|
|
144
|
+
if (resourceExhausted)
|
|
145
|
+
watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
|
|
146
|
+
console.warn('[file-watch] Native subscription callback failed; degrading to manual refresh', {
|
|
147
|
+
rootPath,
|
|
148
|
+
code: getWatchErrorCode(error),
|
|
149
|
+
activeSubscriptions: countActiveSharedWatches(),
|
|
150
|
+
registrySize: sharedWatchRegistry.size,
|
|
151
|
+
resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
|
|
152
|
+
error: reason,
|
|
153
|
+
});
|
|
154
|
+
const failedSubscription = entry.subscription;
|
|
155
|
+
entry.subscription = null;
|
|
156
|
+
void failedSubscription?.unsubscribe().catch(() => undefined);
|
|
157
|
+
if (resourceExhausted) {
|
|
158
|
+
void inspectLinuxInotifyUsage().then((diagnostics) => {
|
|
159
|
+
if (diagnostics)
|
|
160
|
+
console.warn('[file-watch] Linux inotify usage after callback failure', diagnostics);
|
|
161
|
+
}).catch(() => undefined);
|
|
162
|
+
}
|
|
163
|
+
for (const client of [...entry.clients]) {
|
|
71
164
|
if (!client.closed)
|
|
72
|
-
client.
|
|
165
|
+
client.degrade(reason);
|
|
73
166
|
}
|
|
74
167
|
return;
|
|
75
168
|
}
|
|
76
|
-
|
|
77
|
-
const changedPath = path.resolve(event.path);
|
|
78
|
-
if (!isPathInside(rootPath, changedPath) || isIgnoredWatchPath(rootPath, changedPath))
|
|
79
|
-
continue;
|
|
80
|
-
if (event.type === 'delete') {
|
|
81
|
-
for (const client of entry.clients) {
|
|
82
|
-
if (!client.closed)
|
|
83
|
-
client.enqueue({ type: 'deleted', path: changedPath });
|
|
84
|
-
}
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
fs.promises.lstat(changedPath)
|
|
88
|
-
.then((stat) => {
|
|
89
|
-
const payload = {
|
|
90
|
-
type: event.type === 'create' ? 'created' : 'updated',
|
|
91
|
-
path: changedPath,
|
|
92
|
-
entry: toFileEntry(changedPath, stat),
|
|
93
|
-
};
|
|
94
|
-
for (const client of entry.clients) {
|
|
95
|
-
if (!client.closed)
|
|
96
|
-
client.enqueue(payload);
|
|
97
|
-
}
|
|
98
|
-
})
|
|
99
|
-
.catch(() => {
|
|
100
|
-
for (const client of entry.clients) {
|
|
101
|
-
if (!client.closed)
|
|
102
|
-
client.enqueue({ type: 'deleted', path: changedPath });
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
}
|
|
169
|
+
enqueueSharedNativeEvents(rootPath, entry, events);
|
|
106
170
|
};
|
|
107
171
|
}
|
|
108
172
|
async function acquireSharedWatch(rootPath, client) {
|
|
@@ -115,6 +179,10 @@ async function acquireSharedWatch(rootPath, client) {
|
|
|
115
179
|
clients: new Set(),
|
|
116
180
|
inFlight: null,
|
|
117
181
|
evictTimer: null,
|
|
182
|
+
pendingEvents: new Map(),
|
|
183
|
+
statWorkers: 0,
|
|
184
|
+
eventGeneration: 0,
|
|
185
|
+
needsRescanOnAttach: false,
|
|
118
186
|
};
|
|
119
187
|
sharedWatchRegistry.set(rootPath, entry);
|
|
120
188
|
}
|
|
@@ -124,11 +192,32 @@ async function acquireSharedWatch(rootPath, client) {
|
|
|
124
192
|
}
|
|
125
193
|
entry.refs += 1;
|
|
126
194
|
entry.clients.add(client);
|
|
195
|
+
if (entry.needsRescanOnAttach) {
|
|
196
|
+
entry.needsRescanOnAttach = false;
|
|
197
|
+
client.enqueue({ type: 'rescan-required', path: rootPath, reason: 'reconnected' });
|
|
198
|
+
}
|
|
127
199
|
if (entry.failure)
|
|
128
200
|
return null;
|
|
129
201
|
if (entry.subscription)
|
|
130
202
|
return rootPath;
|
|
131
203
|
if (!entry.inFlight) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const activeSubscriptions = countActiveSharedWatches();
|
|
206
|
+
if (now < watchAdmissionBlockedUntil) {
|
|
207
|
+
entry.failure = `native watcher admission paused for ${Math.ceil((watchAdmissionBlockedUntil - now) / 1000)}s after resource exhaustion`;
|
|
208
|
+
console.warn('[file-watch] Subscription skipped while resource backoff is active', {
|
|
209
|
+
rootPath,
|
|
210
|
+
activeSubscriptions,
|
|
211
|
+
registrySize: sharedWatchRegistry.size,
|
|
212
|
+
});
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const startedAt = Date.now();
|
|
216
|
+
console.info('[file-watch] Starting native subscription', {
|
|
217
|
+
rootPath,
|
|
218
|
+
activeSubscriptions: activeSubscriptions + 1,
|
|
219
|
+
registrySize: sharedWatchRegistry.size,
|
|
220
|
+
});
|
|
132
221
|
entry.inFlight = watcher.subscribe(rootPath, sharedWatchCallback(rootPath, entry), {
|
|
133
222
|
ignore: Array.from(WATCH_IGNORED_NAMES).map((name) => `**/${name}/**`),
|
|
134
223
|
})
|
|
@@ -141,12 +230,36 @@ async function acquireSharedWatch(rootPath, client) {
|
|
|
141
230
|
return null;
|
|
142
231
|
}
|
|
143
232
|
current.subscription = subscription;
|
|
233
|
+
console.info('[file-watch] Native subscription ready', {
|
|
234
|
+
rootPath,
|
|
235
|
+
scanDurationMs: Date.now() - startedAt,
|
|
236
|
+
activeSubscriptions: countActiveSharedWatches(),
|
|
237
|
+
registrySize: sharedWatchRegistry.size,
|
|
238
|
+
});
|
|
144
239
|
return subscription;
|
|
145
240
|
})
|
|
146
241
|
.catch((error) => {
|
|
147
242
|
const current = sharedWatchRegistry.get(rootPath);
|
|
148
243
|
if (current)
|
|
149
244
|
current.failure = error instanceof Error ? error.message : String(error);
|
|
245
|
+
const resourceExhausted = isWatchResourceExhaustion(error);
|
|
246
|
+
if (resourceExhausted)
|
|
247
|
+
watchAdmissionBlockedUntil = Date.now() + WATCH_RESOURCE_BACKOFF_MS;
|
|
248
|
+
console.warn('[file-watch] Native subscription failed; degrading to manual refresh', {
|
|
249
|
+
rootPath,
|
|
250
|
+
code: getWatchErrorCode(error),
|
|
251
|
+
scanDurationMs: Date.now() - startedAt,
|
|
252
|
+
activeSubscriptions: countActiveSharedWatches(),
|
|
253
|
+
registrySize: sharedWatchRegistry.size,
|
|
254
|
+
resourceBackoffMs: resourceExhausted ? WATCH_RESOURCE_BACKOFF_MS : 0,
|
|
255
|
+
error: error instanceof Error ? error.message : String(error),
|
|
256
|
+
});
|
|
257
|
+
if (resourceExhausted) {
|
|
258
|
+
void inspectLinuxInotifyUsage().then((diagnostics) => {
|
|
259
|
+
if (diagnostics)
|
|
260
|
+
console.warn('[file-watch] Linux inotify usage after resource exhaustion', diagnostics);
|
|
261
|
+
}).catch(() => undefined);
|
|
262
|
+
}
|
|
150
263
|
return null;
|
|
151
264
|
})
|
|
152
265
|
.finally(() => {
|
|
@@ -168,6 +281,13 @@ function releaseSharedWatch(rootPath, client) {
|
|
|
168
281
|
entry.refs -= 1;
|
|
169
282
|
if (entry.refs > 0)
|
|
170
283
|
return;
|
|
284
|
+
entry.needsRescanOnAttach = true;
|
|
285
|
+
entry.pendingEvents.clear();
|
|
286
|
+
entry.eventGeneration += 1;
|
|
287
|
+
if (!entry.subscription && !entry.inFlight) {
|
|
288
|
+
sharedWatchRegistry.delete(rootPath);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
171
291
|
if (entry.evictTimer)
|
|
172
292
|
clearTimeout(entry.evictTimer);
|
|
173
293
|
entry.evictTimer = setTimeout(() => {
|
|
@@ -414,9 +534,6 @@ function compareDirents(a, b) {
|
|
|
414
534
|
return 1;
|
|
415
535
|
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
416
536
|
}
|
|
417
|
-
const WATCH_IGNORED_NAMES = new Set([
|
|
418
|
-
'.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
|
|
419
|
-
]);
|
|
420
537
|
const NESTED_GIT_DISCOVERY_IGNORED_NAMES = new Set([
|
|
421
538
|
'.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
|
|
422
539
|
'.cache', '.parcel-cache', '.yarn', '.pnpm-store', 'vendor',
|
|
@@ -1353,11 +1470,12 @@ function toFileEntry(entryPath, stat, isSymlink = false) {
|
|
|
1353
1470
|
path: entryPath,
|
|
1354
1471
|
type: stat.isDirectory() ? 'directory' : stat.isSymbolicLink() ? 'symlink' : 'file',
|
|
1355
1472
|
isSymlink,
|
|
1473
|
+
modified: stat.mtime.toISOString(),
|
|
1356
1474
|
};
|
|
1357
1475
|
}
|
|
1358
|
-
async function toDirectoryEntry(dir, dirent) {
|
|
1476
|
+
async function toDirectoryEntry(dir, dirent, includeModified = false) {
|
|
1359
1477
|
const entryPath = path.join(dir, dirent.name);
|
|
1360
|
-
if (!dirent.isSymbolicLink()) {
|
|
1478
|
+
if (!dirent.isSymbolicLink() && !includeModified) {
|
|
1361
1479
|
return {
|
|
1362
1480
|
name: dirent.name,
|
|
1363
1481
|
path: entryPath,
|
|
@@ -1369,19 +1487,39 @@ async function toDirectoryEntry(dir, dirent) {
|
|
|
1369
1487
|
return {
|
|
1370
1488
|
name: dirent.name,
|
|
1371
1489
|
path: entryPath,
|
|
1372
|
-
type: stat.isDirectory() ? 'directory' : 'symlink',
|
|
1373
|
-
isSymlink:
|
|
1490
|
+
type: stat.isDirectory() ? 'directory' : dirent.isSymbolicLink() ? 'symlink' : 'file',
|
|
1491
|
+
isSymlink: dirent.isSymbolicLink() || undefined,
|
|
1492
|
+
modified: includeModified ? stat.mtime.toISOString() : undefined,
|
|
1374
1493
|
};
|
|
1375
1494
|
}
|
|
1376
1495
|
catch {
|
|
1377
1496
|
return {
|
|
1378
1497
|
name: dirent.name,
|
|
1379
1498
|
path: entryPath,
|
|
1380
|
-
type: 'symlink',
|
|
1381
|
-
isSymlink:
|
|
1499
|
+
type: dirent.isDirectory() ? 'directory' : dirent.isSymbolicLink() ? 'symlink' : 'file',
|
|
1500
|
+
isSymlink: dirent.isSymbolicLink() || undefined,
|
|
1382
1501
|
};
|
|
1383
1502
|
}
|
|
1384
1503
|
}
|
|
1504
|
+
async function loadDirectoryEntriesWithModified(dir, dirents, signal) {
|
|
1505
|
+
const entries = [];
|
|
1506
|
+
const batchSize = 64;
|
|
1507
|
+
for (let offset = 0; offset < dirents.length; offset += batchSize) {
|
|
1508
|
+
throwIfAborted(signal, 'fs.list');
|
|
1509
|
+
entries.push(...await Promise.all(dirents.slice(offset, offset + batchSize).map((dirent) => toDirectoryEntry(dir, dirent, true))));
|
|
1510
|
+
}
|
|
1511
|
+
return entries;
|
|
1512
|
+
}
|
|
1513
|
+
function compareDirectoryEntriesByModified(a, b) {
|
|
1514
|
+
if (a.type === 'directory' && b.type !== 'directory')
|
|
1515
|
+
return -1;
|
|
1516
|
+
if (a.type !== 'directory' && b.type === 'directory')
|
|
1517
|
+
return 1;
|
|
1518
|
+
const modifiedDelta = Date.parse(b.modified ?? '') - Date.parse(a.modified ?? '');
|
|
1519
|
+
if (Number.isFinite(modifiedDelta) && modifiedDelta !== 0)
|
|
1520
|
+
return modifiedDelta;
|
|
1521
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
1522
|
+
}
|
|
1385
1523
|
function isIgnoredWatchPath(rootPath, candidatePath) {
|
|
1386
1524
|
const relative = path.relative(rootPath, candidatePath);
|
|
1387
1525
|
if (!relative || relative.startsWith('..') || path.isAbsolute(relative))
|
|
@@ -2378,13 +2516,18 @@ router.get('/list', async (req, res) => {
|
|
|
2378
2516
|
throw new Error('Path is not a directory');
|
|
2379
2517
|
}
|
|
2380
2518
|
const showHidden = req.query.showHidden === 'true';
|
|
2519
|
+
const sortMode = req.query.sort === 'modified' ? 'modified' : 'name';
|
|
2381
2520
|
const allEntries = await fs.promises.readdir(resolvedPath, { withFileTypes: true });
|
|
2382
2521
|
throwIfAborted(controller.signal, 'fs.list');
|
|
2383
2522
|
const visibleDirents = allEntries.filter(dirent => showHidden || !dirent.name.startsWith('.'));
|
|
2384
|
-
const
|
|
2385
|
-
.
|
|
2386
|
-
|
|
2387
|
-
|
|
2523
|
+
const entries = sortMode === 'modified'
|
|
2524
|
+
? (await loadDirectoryEntriesWithModified(resolvedPath, visibleDirents, controller.signal))
|
|
2525
|
+
.sort(compareDirectoryEntriesByModified)
|
|
2526
|
+
.slice(0, MAX_DIRECTORY_ENTRIES)
|
|
2527
|
+
: await Promise.all(visibleDirents
|
|
2528
|
+
.sort(compareDirents)
|
|
2529
|
+
.slice(0, MAX_DIRECTORY_ENTRIES)
|
|
2530
|
+
.map((dirent) => toDirectoryEntry(resolvedPath, dirent)));
|
|
2388
2531
|
throwIfAborted(controller.signal, 'fs.list');
|
|
2389
2532
|
return { resolvedPath, entries, total: visibleDirents.length };
|
|
2390
2533
|
})(), FS_ROUTE_TIMEOUT_MS, 'Directory listing took too long. The folder may be on a slow disk or network mount.', 'FS_LIST_TIMEOUT');
|
|
@@ -2410,20 +2553,21 @@ router.get('/watch', async (req, res) => {
|
|
|
2410
2553
|
const rootsParam = req.query.roots;
|
|
2411
2554
|
const rawRoots = (Array.isArray(rootsParam) ? rootsParam : typeof rootsParam === 'string' ? rootsParam.split('|') : [])
|
|
2412
2555
|
.filter((root) => typeof root === 'string');
|
|
2413
|
-
const
|
|
2556
|
+
const validatedRoots = [];
|
|
2414
2557
|
for (const rawRoot of rawRoots) {
|
|
2415
2558
|
if (!rawRoot)
|
|
2416
2559
|
continue;
|
|
2417
2560
|
try {
|
|
2418
2561
|
const resolved = await pathValidator.validatePathAsync(rawRoot);
|
|
2419
2562
|
const stat = await fs.promises.stat(resolved);
|
|
2420
|
-
if (stat.isDirectory() && !
|
|
2421
|
-
|
|
2563
|
+
if (stat.isDirectory() && !validatedRoots.includes(resolved))
|
|
2564
|
+
validatedRoots.push(resolved);
|
|
2422
2565
|
}
|
|
2423
2566
|
catch {
|
|
2424
2567
|
// Ignore invalid watch roots; the visible tree will still work via manual list requests.
|
|
2425
2568
|
}
|
|
2426
2569
|
}
|
|
2570
|
+
const roots = minimizeRecursiveWatchRoots(validatedRoots);
|
|
2427
2571
|
if (roots.length === 0) {
|
|
2428
2572
|
res.status(400).json({ error: 'No valid roots to watch' });
|
|
2429
2573
|
return;
|
|
@@ -2435,6 +2579,21 @@ router.get('/watch', async (req, res) => {
|
|
|
2435
2579
|
let pending = [];
|
|
2436
2580
|
let flushTimer = null;
|
|
2437
2581
|
const attachedRoots = new Map();
|
|
2582
|
+
// Register cleanup before the first native subscription starts. A recursive
|
|
2583
|
+
// initial scan can take seconds on a large repository; the browser may abort
|
|
2584
|
+
// during that await when the user switches files or roots. Registering this
|
|
2585
|
+
// only after attachRoot() leaked the pending subscription and its reference.
|
|
2586
|
+
const closeWatchStream = () => {
|
|
2587
|
+
if (closed)
|
|
2588
|
+
return;
|
|
2589
|
+
closed = true;
|
|
2590
|
+
if (flushTimer)
|
|
2591
|
+
clearTimeout(flushTimer);
|
|
2592
|
+
for (const [rootPath, client] of attachedRoots) {
|
|
2593
|
+
releaseSharedWatch(rootPath, client);
|
|
2594
|
+
}
|
|
2595
|
+
};
|
|
2596
|
+
res.once('close', closeWatchStream);
|
|
2438
2597
|
const writeEvent = (type, payload) => {
|
|
2439
2598
|
if (closed || res.destroyed)
|
|
2440
2599
|
return;
|
|
@@ -2463,28 +2622,43 @@ router.get('/watch', async (req, res) => {
|
|
|
2463
2622
|
}
|
|
2464
2623
|
};
|
|
2465
2624
|
writeEvent('ready', { roots });
|
|
2625
|
+
console.info('[file-watch] Stream ready', {
|
|
2626
|
+
requestedRoots: rawRoots.length,
|
|
2627
|
+
validatedRoots: validatedRoots.length,
|
|
2628
|
+
subscribedRoots: roots.length,
|
|
2629
|
+
collapsedOverlappingRoots: validatedRoots.length - roots.length,
|
|
2630
|
+
activeSubscriptions: countActiveSharedWatches(),
|
|
2631
|
+
registrySize: sharedWatchRegistry.size,
|
|
2632
|
+
});
|
|
2466
2633
|
const attachRoot = async (rootPath) => {
|
|
2467
|
-
|
|
2634
|
+
if (closed)
|
|
2635
|
+
return;
|
|
2636
|
+
const client = {
|
|
2637
|
+
closed: false,
|
|
2638
|
+
enqueue,
|
|
2639
|
+
degrade: (reason) => {
|
|
2640
|
+
if (closed)
|
|
2641
|
+
return;
|
|
2642
|
+
enqueue({ type: 'rescan-required', path: rootPath, reason });
|
|
2643
|
+
flush();
|
|
2644
|
+
if (!res.writableEnded)
|
|
2645
|
+
res.end();
|
|
2646
|
+
},
|
|
2647
|
+
};
|
|
2468
2648
|
attachedRoots.set(rootPath, client);
|
|
2469
2649
|
const ok = await acquireSharedWatch(rootPath, client);
|
|
2470
2650
|
if (closed)
|
|
2471
2651
|
return;
|
|
2472
2652
|
if (!ok) {
|
|
2473
2653
|
const reason = sharedWatchRegistry.get(rootPath)?.failure ?? 'watch-unavailable';
|
|
2474
|
-
|
|
2654
|
+
client.degrade(reason);
|
|
2475
2655
|
}
|
|
2476
2656
|
};
|
|
2477
2657
|
for (const rootPath of roots) {
|
|
2658
|
+
if (closed)
|
|
2659
|
+
break;
|
|
2478
2660
|
await attachRoot(rootPath);
|
|
2479
2661
|
}
|
|
2480
|
-
req.on('close', () => {
|
|
2481
|
-
closed = true;
|
|
2482
|
-
if (flushTimer)
|
|
2483
|
-
clearTimeout(flushTimer);
|
|
2484
|
-
for (const [rootPath, client] of attachedRoots) {
|
|
2485
|
-
releaseSharedWatch(rootPath, client);
|
|
2486
|
-
}
|
|
2487
|
-
});
|
|
2488
2662
|
});
|
|
2489
2663
|
router.get('/cancel-slot', (req, res) => {
|
|
2490
2664
|
const requestSlotId = typeof req.query.requestSlotId === 'string' ? req.query.requestSlotId : undefined;
|
|
@@ -14,9 +14,10 @@ import { getPtyHostManager } from '../ptyhost/manager.js';
|
|
|
14
14
|
import { pathValidator } from '../utils/pathValidator.js';
|
|
15
15
|
import { TERMINAL, TMUX } from '../config.js';
|
|
16
16
|
import { localAccessManager } from '../utils/localAccess.js';
|
|
17
|
-
import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, getAutoRenameAgentsSetting, setAutoRenameAgentsSetting, getAutoRenameNamerSetting, setAutoRenameNamerSetting, getAutoRenameModelsSetting, setAutoRenameModelsSetting, getAutoRenameIntervalMinutesSetting, setAutoRenameIntervalMinutesSetting, getAutoRenamePromptPreferenceSetting, setAutoRenamePromptPreferenceSetting, getAutoRenamePromptPayloadCharsSetting, setAutoRenamePromptPayloadCharsSetting, getNewSessionAgentSlugSetting, setNewSessionAgentSlugSetting, getRunningSessionButtonEnabledSetting, setRunningSessionButtonEnabledSetting, } from '../utils/settings.js';
|
|
17
|
+
import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, getAutoRenameAgentsSetting, setAutoRenameAgentsSetting, getAutoRenameNamerSetting, setAutoRenameNamerSetting, getAutoRenameModelsSetting, setAutoRenameModelsSetting, getAutoRenameIntervalMinutesSetting, setAutoRenameIntervalMinutesSetting, getAutoRenamePromptPreferenceSetting, setAutoRenamePromptPreferenceSetting, getAutoRenamePromptPayloadCharsSetting, setAutoRenamePromptPayloadCharsSetting, getNewSessionAgentSlugSetting, setNewSessionAgentSlugSetting, getRunningSessionButtonEnabledSetting, setRunningSessionButtonEnabledSetting, getFileSortModesSetting, setFileSortModesSetting, setFileSortModeSetting, } from '../utils/settings.js';
|
|
18
18
|
import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
|
|
19
19
|
import { getOnboardingServerUrl } from '../onboardingServer.js';
|
|
20
|
+
import { inspectLinuxInotifyUsage, isWatchResourceExhaustion } from '../utils/fileWatchPolicy.js';
|
|
20
21
|
import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
|
|
21
22
|
import { extractProgramLabelFromArgs, normalizeProgramName, normalizeTmuxMetadataProgram, selectTmuxForegroundProgram, tmuxMetadataChanged, } from '../utils/tmuxProgramDetection.js';
|
|
22
23
|
import { agentBySlug, buildResumeCommand, detectAgentFromCommand, inferResumeSessionId, listAgents, } from '../agent/registry.js';
|
|
@@ -457,6 +458,15 @@ function scheduleReloadGlobalSessionState() {
|
|
|
457
458
|
globalSessionStateReloadTimer.unref?.();
|
|
458
459
|
}
|
|
459
460
|
async function watchGlobalSessionStateFile() {
|
|
461
|
+
const reportFailure = (message, error) => {
|
|
462
|
+
console.warn(message, getErrorMessage(error));
|
|
463
|
+
if (!isWatchResourceExhaustion(error))
|
|
464
|
+
return;
|
|
465
|
+
void inspectLinuxInotifyUsage().then((diagnostics) => {
|
|
466
|
+
if (diagnostics)
|
|
467
|
+
console.warn('[session-persist] Linux inotify usage after watcher failure:', diagnostics);
|
|
468
|
+
}).catch(() => undefined);
|
|
469
|
+
};
|
|
460
470
|
try {
|
|
461
471
|
const dir = path.dirname(GLOBAL_SESSION_STATE_FILE);
|
|
462
472
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
@@ -468,11 +478,11 @@ async function watchGlobalSessionStateFile() {
|
|
|
468
478
|
scheduleReloadGlobalSessionState();
|
|
469
479
|
});
|
|
470
480
|
globalSessionStateWatcher.on('error', (error) => {
|
|
471
|
-
|
|
481
|
+
reportFailure('[session-persist] Global state watcher failed:', error);
|
|
472
482
|
});
|
|
473
483
|
}
|
|
474
484
|
catch (error) {
|
|
475
|
-
|
|
485
|
+
reportFailure('[session-persist] Failed to watch global state file:', error);
|
|
476
486
|
}
|
|
477
487
|
}
|
|
478
488
|
function schedulePersistGlobalState() {
|
|
@@ -5566,6 +5576,7 @@ async function getSettingsPayload() {
|
|
|
5566
5576
|
autoRenamePromptPayloadChars: getAutoRenamePromptPayloadCharsSetting(),
|
|
5567
5577
|
newSessionAgentSlug: getNewSessionAgentSlugSetting(),
|
|
5568
5578
|
runningSessionButtonEnabled: getRunningSessionButtonEnabledSetting(),
|
|
5579
|
+
fileSortModes: getFileSortModesSetting(),
|
|
5569
5580
|
localAccess: {
|
|
5570
5581
|
...localAccess,
|
|
5571
5582
|
interfaces,
|
|
@@ -5677,6 +5688,21 @@ router.put('/settings', async (req, res) => {
|
|
|
5677
5688
|
if (typeof body.runningSessionButtonEnabled === 'boolean') {
|
|
5678
5689
|
setRunningSessionButtonEnabledSetting(body.runningSessionButtonEnabled);
|
|
5679
5690
|
}
|
|
5691
|
+
if (body.fileSortModes && typeof body.fileSortModes === 'object' && !Array.isArray(body.fileSortModes)) {
|
|
5692
|
+
setFileSortModesSetting(body.fileSortModes);
|
|
5693
|
+
}
|
|
5694
|
+
if (body.fileSortMode && typeof body.fileSortMode === 'object') {
|
|
5695
|
+
const preference = body.fileSortMode;
|
|
5696
|
+
const validPath = typeof preference.path === 'string'
|
|
5697
|
+
&& preference.path.length > 0
|
|
5698
|
+
&& preference.path.length <= 4096
|
|
5699
|
+
&& (preference.path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(preference.path));
|
|
5700
|
+
if (!validPath || (preference.mode !== 'name' && preference.mode !== 'modified')) {
|
|
5701
|
+
res.status(400).json({ error: 'Invalid file sort preference', code: 'FILE_SORT_MODE_INVALID' });
|
|
5702
|
+
return;
|
|
5703
|
+
}
|
|
5704
|
+
setFileSortModeSetting(preference.path, preference.mode);
|
|
5705
|
+
}
|
|
5680
5706
|
if (body.localAccess && typeof body.localAccess === 'object') {
|
|
5681
5707
|
const localAccessBody = body.localAccess;
|
|
5682
5708
|
if (localAccessBody.reset === true) {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
export const WATCH_IGNORED_NAMES = new Set([
|
|
4
|
+
'.git', 'node_modules', 'dist', 'build', '.next', '.nuxt', '.turbo', 'coverage', 'target', '.gradle', '.idea', '.DS_Store',
|
|
5
|
+
]);
|
|
6
|
+
export const WATCH_RESOURCE_BACKOFF_MS = 60_000;
|
|
7
|
+
export function enqueueLatestWatchEvent(pending, eventPath, eventType, maxPending) {
|
|
8
|
+
if (!pending.has(eventPath) && pending.size >= maxPending)
|
|
9
|
+
return 'overflow';
|
|
10
|
+
pending.set(eventPath, eventType);
|
|
11
|
+
return 'queued';
|
|
12
|
+
}
|
|
13
|
+
function isSameOrDescendant(parentPath, candidatePath) {
|
|
14
|
+
const relative = path.relative(parentPath, candidatePath);
|
|
15
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Recursive watchers already cover every descendant. Keep only the shallowest
|
|
19
|
+
* roots so expanding a folder inside an already watched folder cannot allocate
|
|
20
|
+
* another overlapping native watcher.
|
|
21
|
+
*/
|
|
22
|
+
export function minimizeRecursiveWatchRoots(roots) {
|
|
23
|
+
const uniqueRoots = [...new Set(roots.map((root) => path.resolve(root)))];
|
|
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;
|
|
31
|
+
}
|
|
32
|
+
export function getWatchErrorCode(error) {
|
|
33
|
+
if (!error || typeof error !== 'object')
|
|
34
|
+
return null;
|
|
35
|
+
const code = error.code;
|
|
36
|
+
return typeof code === 'string' ? code.toUpperCase() : null;
|
|
37
|
+
}
|
|
38
|
+
export function isWatchResourceExhaustion(error) {
|
|
39
|
+
const code = getWatchErrorCode(error);
|
|
40
|
+
if (code === 'EMFILE' || code === 'ENFILE' || code === 'ENOSPC')
|
|
41
|
+
return true;
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
return /\b(?:EMFILE|ENFILE|ENOSPC)\b|too many open files|inotify/i.test(message);
|
|
44
|
+
}
|
|
45
|
+
export function countInotifyWatchDescriptors(fdInfo) {
|
|
46
|
+
return fdInfo.split('\n').filter((line) => line.startsWith('inotify wd:')).length;
|
|
47
|
+
}
|
|
48
|
+
/** Best-effort Linux-only snapshot. Call only on watcher failures, never on the hot path. */
|
|
49
|
+
export async function inspectLinuxInotifyUsage() {
|
|
50
|
+
if (process.platform !== 'linux' || typeof process.getuid !== 'function')
|
|
51
|
+
return null;
|
|
52
|
+
const uid = process.getuid();
|
|
53
|
+
let partial = false;
|
|
54
|
+
let maxUserInstances = null;
|
|
55
|
+
let maxUserWatches = null;
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.promises.readFile('/proc/sys/fs/inotify/max_user_instances', 'utf8');
|
|
58
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
59
|
+
if (Number.isFinite(parsed))
|
|
60
|
+
maxUserInstances = parsed;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
partial = true;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const raw = await fs.promises.readFile('/proc/sys/fs/inotify/max_user_watches', 'utf8');
|
|
67
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
68
|
+
if (Number.isFinite(parsed))
|
|
69
|
+
maxUserWatches = parsed;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
partial = true;
|
|
73
|
+
}
|
|
74
|
+
let procEntries;
|
|
75
|
+
try {
|
|
76
|
+
procEntries = await fs.promises.readdir('/proc', { withFileTypes: true });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { maxUserInstances, maxUserWatches, observedUserInstances: 0, observedUserWatches: 0, topConsumers: [], partial: true };
|
|
80
|
+
}
|
|
81
|
+
const consumers = [];
|
|
82
|
+
for (const procEntry of procEntries) {
|
|
83
|
+
if (!procEntry.isDirectory() || !/^\d+$/.test(procEntry.name))
|
|
84
|
+
continue;
|
|
85
|
+
const pid = Number(procEntry.name);
|
|
86
|
+
const procPath = `/proc/${procEntry.name}`;
|
|
87
|
+
try {
|
|
88
|
+
if ((await fs.promises.stat(procPath)).uid !== uid)
|
|
89
|
+
continue;
|
|
90
|
+
const fdEntries = await fs.promises.readdir(`${procPath}/fd`);
|
|
91
|
+
let instances = 0;
|
|
92
|
+
let watches = 0;
|
|
93
|
+
for (const fd of fdEntries) {
|
|
94
|
+
try {
|
|
95
|
+
if ((await fs.promises.readlink(`${procPath}/fd/${fd}`)) !== 'anon_inode:inotify')
|
|
96
|
+
continue;
|
|
97
|
+
instances += 1;
|
|
98
|
+
try {
|
|
99
|
+
watches += countInotifyWatchDescriptors(await fs.promises.readFile(`${procPath}/fdinfo/${fd}`, 'utf8'));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
partial = true;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Processes and descriptors can disappear while /proc is sampled.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (instances === 0)
|
|
110
|
+
continue;
|
|
111
|
+
let command = procEntry.name;
|
|
112
|
+
try {
|
|
113
|
+
const rawCommand = await fs.promises.readFile(`${procPath}/comm`, 'utf8');
|
|
114
|
+
command = rawCommand.trim() || command;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
partial = true;
|
|
118
|
+
}
|
|
119
|
+
consumers.push({ pid, command, instances, watches });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const code = getWatchErrorCode(error);
|
|
123
|
+
if (code !== 'ENOENT' && code !== 'ESRCH')
|
|
124
|
+
partial = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
consumers.sort((a, b) => b.watches - a.watches || b.instances - a.instances || a.pid - b.pid);
|
|
128
|
+
return {
|
|
129
|
+
maxUserInstances,
|
|
130
|
+
maxUserWatches,
|
|
131
|
+
observedUserInstances: consumers.reduce((total, item) => total + item.instances, 0),
|
|
132
|
+
observedUserWatches: consumers.reduce((total, item) => total + item.watches, 0),
|
|
133
|
+
topConsumers: consumers.slice(0, 8),
|
|
134
|
+
partial,
|
|
135
|
+
};
|
|
136
|
+
}
|