lsoph 0.0.1__py3-none-any.whl
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.
- lsoph/__init__.py +0 -0
- lsoph/backend/__init__.py +0 -0
- lsoph/backend/lsof.py +329 -0
- lsoph/backend/psutil.py +357 -0
- lsoph/backend/strace.py +604 -0
- lsoph/backend/strace_cmd.py +426 -0
- lsoph/cli.py +218 -0
- lsoph/monitor.py +566 -0
- lsoph/ui/__init__.py +0 -0
- lsoph/ui/app.py +464 -0
- lsoph/ui/detail_screen.py +84 -0
- lsoph/ui/log_screen.py +98 -0
- lsoph/util/__init__.py +0 -0
- lsoph/util/pid.py +147 -0
- lsoph/util/short_path.py +83 -0
- lsoph/util/versioned.py +61 -0
- lsoph-0.0.1.dist-info/METADATA +92 -0
- lsoph-0.0.1.dist-info/RECORD +21 -0
- lsoph-0.0.1.dist-info/WHEEL +4 -0
- lsoph-0.0.1.dist-info/entry_points.txt +3 -0
- lsoph-0.0.1.dist-info/licenses/LICENSE.md +7 -0
lsoph/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
lsoph/backend/lsof.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from typing import Dict, Iterator, List, Optional, Set, Tuple
|
|
7
|
+
|
|
8
|
+
from lsoph.monitor import Monitor
|
|
9
|
+
from lsoph.util.pid import get_descendants
|
|
10
|
+
|
|
11
|
+
# Setup logging - using the same pattern as other modules
|
|
12
|
+
logging.basicConfig(
|
|
13
|
+
level=os.environ.get("LOGLEVEL", "WARNING").upper(),
|
|
14
|
+
format="%(levelname)s:%(name)s:%(message)s",
|
|
15
|
+
)
|
|
16
|
+
log = logging.getLogger("lsoph.backend.lsof") # Use package-aware logger name
|
|
17
|
+
|
|
18
|
+
# Regular expressions for parsing lsof output
|
|
19
|
+
# Format: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
20
|
+
LSOF_LINE_RE = re.compile(
|
|
21
|
+
r"^(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.+)$"
|
|
22
|
+
)
|
|
23
|
+
FD_TYPE_RE = re.compile(r"(\d+)([rwu])?")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_fd(fd_str: str) -> Tuple[Optional[int], str]:
|
|
27
|
+
"""Parse the FD column from lsof output."""
|
|
28
|
+
if fd_str in ("cwd", "rtd", "txt", "mem"):
|
|
29
|
+
log.debug(f"Special FD type: {fd_str}")
|
|
30
|
+
return None, fd_str
|
|
31
|
+
|
|
32
|
+
match = FD_TYPE_RE.match(fd_str)
|
|
33
|
+
if match:
|
|
34
|
+
fd = int(match.group(1))
|
|
35
|
+
mode = match.group(2) or ""
|
|
36
|
+
log.debug(f"Parsed FD {fd} with mode '{mode}'")
|
|
37
|
+
return fd, mode
|
|
38
|
+
|
|
39
|
+
log.debug(f"Unparsable FD string: {fd_str}")
|
|
40
|
+
return None, fd_str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _run_lsof(pids: Optional[List[int]] = None) -> Iterator[Dict]:
|
|
44
|
+
"""Run lsof and yield parsed output lines."""
|
|
45
|
+
cmd = ["lsof", "-n", "-F", "pcftn"] # p=pid, c=command, f=fd, t=type, n=name
|
|
46
|
+
|
|
47
|
+
if pids:
|
|
48
|
+
cmd.extend(["-p", ",".join(map(str, pids))])
|
|
49
|
+
|
|
50
|
+
log.info(f"Running lsof command: {' '.join(cmd)}")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
# Redirect stderr to /dev/null to suppress tracefs warnings
|
|
54
|
+
with open(os.devnull, "w") as devnull:
|
|
55
|
+
proc = subprocess.Popen(
|
|
56
|
+
cmd, stdout=subprocess.PIPE, stderr=devnull, text=True
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
current_record = {}
|
|
60
|
+
for line in proc.stdout:
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
field_type = line[0]
|
|
66
|
+
value = line[1:]
|
|
67
|
+
|
|
68
|
+
if field_type == "p" and current_record:
|
|
69
|
+
# New process, yield previous record
|
|
70
|
+
yield current_record
|
|
71
|
+
current_record = {"pid": int(value)}
|
|
72
|
+
elif field_type == "p":
|
|
73
|
+
current_record = {"pid": int(value)}
|
|
74
|
+
elif field_type == "c":
|
|
75
|
+
current_record["command"] = value
|
|
76
|
+
elif field_type == "f":
|
|
77
|
+
current_record["fd_str"] = value
|
|
78
|
+
fd, mode = _parse_fd(value)
|
|
79
|
+
current_record["fd"] = fd
|
|
80
|
+
current_record["mode"] = mode
|
|
81
|
+
elif field_type == "t":
|
|
82
|
+
current_record["type"] = value
|
|
83
|
+
elif field_type == "n":
|
|
84
|
+
current_record["path"] = value
|
|
85
|
+
log.debug(
|
|
86
|
+
f"Complete record: PID {current_record.get('pid')}, FD {current_record.get('fd')}, path {value}"
|
|
87
|
+
)
|
|
88
|
+
yield current_record
|
|
89
|
+
current_record = {
|
|
90
|
+
"pid": current_record["pid"],
|
|
91
|
+
"command": current_record["command"],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Yield final record if any
|
|
95
|
+
if current_record and "path" in current_record:
|
|
96
|
+
log.debug(f"Final record: {current_record}")
|
|
97
|
+
yield current_record
|
|
98
|
+
|
|
99
|
+
except subprocess.SubprocessError as e:
|
|
100
|
+
log.error(f"Error running lsof: {e}")
|
|
101
|
+
except Exception as e:
|
|
102
|
+
log.exception(f"Unexpected error in _run_lsof: {e}")
|
|
103
|
+
finally:
|
|
104
|
+
if proc.stdout:
|
|
105
|
+
proc.stdout.close()
|
|
106
|
+
exit_code = proc.wait()
|
|
107
|
+
log.debug(f"lsof process exited with code: {exit_code}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _process_lsof_data(record: Dict, monitor: Monitor, timestamp: float) -> None:
|
|
111
|
+
"""Process a parsed lsof record and update the monitor."""
|
|
112
|
+
pid = record.get("pid")
|
|
113
|
+
path = record.get("path")
|
|
114
|
+
fd = record.get("fd")
|
|
115
|
+
|
|
116
|
+
if not (pid and path):
|
|
117
|
+
log.debug(f"Incomplete record missing pid or path: {record}")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
# If fd is None, this is a special file like cwd, not an open fd
|
|
121
|
+
if fd is None:
|
|
122
|
+
fd_str = record.get("fd_str", "unknown")
|
|
123
|
+
log.debug(f"Special file {fd_str} for PID {pid}: {path}")
|
|
124
|
+
monitor.stat(pid, path, True, timestamp)
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# For regular files, add as open
|
|
128
|
+
log.debug(f"Processing open file: PID {pid}, FD {fd}, path {path}")
|
|
129
|
+
monitor.open(pid, path, fd, True, timestamp)
|
|
130
|
+
|
|
131
|
+
# Check mode to determine if it's being read/written
|
|
132
|
+
mode = record.get("mode", "")
|
|
133
|
+
if "r" in mode:
|
|
134
|
+
log.debug(f"File has read mode: PID {pid}, FD {fd}")
|
|
135
|
+
monitor.read(pid, fd, path, True, timestamp)
|
|
136
|
+
if "w" in mode:
|
|
137
|
+
log.debug(f"File has write mode: PID {pid}, FD {fd}")
|
|
138
|
+
monitor.write(pid, fd, path, True, timestamp)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _attach_with_descendants(pids: List[int], monitor: Monitor) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Attach to processes, their descendants, and monitor their open files using lsof.
|
|
144
|
+
This version tracks child processes by periodically checking for descendants.
|
|
145
|
+
"""
|
|
146
|
+
log.info(f"Attaching to initial PIDs: {pids}")
|
|
147
|
+
|
|
148
|
+
# Keep track of all PIDs we're monitoring (including descendants)
|
|
149
|
+
all_pids = set(pids)
|
|
150
|
+
|
|
151
|
+
# Keep track of file descriptors we've seen for each PID
|
|
152
|
+
# to detect closures on subsequent runs
|
|
153
|
+
seen_fds: Dict[int, Set[Tuple[int, str]]] = {}
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
poll_interval = 1.0 # seconds
|
|
157
|
+
child_check_interval = 5.0 # check for new children every 5 polls
|
|
158
|
+
poll_count = 0
|
|
159
|
+
|
|
160
|
+
log.info(f"Starting lsof polling with interval: {poll_interval} seconds")
|
|
161
|
+
|
|
162
|
+
while True:
|
|
163
|
+
timestamp = time.time()
|
|
164
|
+
poll_count += 1
|
|
165
|
+
|
|
166
|
+
# Check for descendants periodically
|
|
167
|
+
if poll_count % child_check_interval < 1:
|
|
168
|
+
log.debug(f"Checking for new child processes of {pids}")
|
|
169
|
+
new_pids = set()
|
|
170
|
+
for parent_pid in pids:
|
|
171
|
+
try:
|
|
172
|
+
descendants = get_descendants(parent_pid)
|
|
173
|
+
for child_pid in descendants:
|
|
174
|
+
if child_pid not in all_pids:
|
|
175
|
+
log.info(f"Found new child process: {child_pid}")
|
|
176
|
+
new_pids.add(child_pid)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
log.debug(
|
|
179
|
+
f"Error checking descendants for PID {parent_pid}: {e}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Add any new PIDs to our tracking set
|
|
183
|
+
if new_pids:
|
|
184
|
+
log.info(
|
|
185
|
+
f"Adding {len(new_pids)} new child processes to monitoring"
|
|
186
|
+
)
|
|
187
|
+
all_pids.update(new_pids)
|
|
188
|
+
|
|
189
|
+
# Convert set back to list for lsof
|
|
190
|
+
current_pids = list(all_pids)
|
|
191
|
+
current_fds: Dict[int, Set[Tuple[int, str]]] = {}
|
|
192
|
+
|
|
193
|
+
record_count = 0
|
|
194
|
+
for record in _run_lsof(current_pids):
|
|
195
|
+
record_count += 1
|
|
196
|
+
pid = record.get("pid")
|
|
197
|
+
fd = record.get("fd")
|
|
198
|
+
path = record.get("path")
|
|
199
|
+
|
|
200
|
+
if pid and fd is not None and path:
|
|
201
|
+
if pid not in current_fds:
|
|
202
|
+
current_fds[pid] = set()
|
|
203
|
+
current_fds[pid].add((fd, path))
|
|
204
|
+
|
|
205
|
+
_process_lsof_data(record, monitor, timestamp)
|
|
206
|
+
|
|
207
|
+
log.debug(f"Processed {record_count} lsof records")
|
|
208
|
+
|
|
209
|
+
# Detect closed files (present in seen_fds but not in current_fds)
|
|
210
|
+
close_count = 0
|
|
211
|
+
for pid, fd_paths in seen_fds.items():
|
|
212
|
+
current_pid_fds = current_fds.get(pid, set())
|
|
213
|
+
for fd, path in fd_paths:
|
|
214
|
+
if (fd, path) not in current_pid_fds:
|
|
215
|
+
log.debug(
|
|
216
|
+
f"Detected closed file: PID {pid}, FD {fd}, path {path}"
|
|
217
|
+
)
|
|
218
|
+
monitor.close(pid, fd, True, timestamp)
|
|
219
|
+
close_count += 1
|
|
220
|
+
|
|
221
|
+
if close_count > 0:
|
|
222
|
+
log.debug(f"Detected {close_count} closed files")
|
|
223
|
+
|
|
224
|
+
seen_fds = current_fds
|
|
225
|
+
log.debug(f"Sleeping for {poll_interval} seconds")
|
|
226
|
+
time.sleep(poll_interval)
|
|
227
|
+
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
log.info("lsof monitoring interrupted by user")
|
|
230
|
+
except Exception as e:
|
|
231
|
+
log.exception(f"Unexpected error in lsof attach: {e}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def attach(pids: List[int], monitor: Monitor) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Standard attach function which monitors the specified PIDs without tracking descendants.
|
|
237
|
+
For command execution with descendants tracking, use _attach_with_descendants.
|
|
238
|
+
"""
|
|
239
|
+
log.info(f"Attaching to PIDs: {pids}")
|
|
240
|
+
|
|
241
|
+
# Keep track of file descriptors we've seen for each PID
|
|
242
|
+
# to detect closures on subsequent runs
|
|
243
|
+
seen_fds: Dict[int, Set[Tuple[int, str]]] = {}
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
poll_interval = 1.0 # seconds
|
|
247
|
+
log.info(f"Starting lsof polling with interval: {poll_interval} seconds")
|
|
248
|
+
|
|
249
|
+
while True:
|
|
250
|
+
timestamp = time.time()
|
|
251
|
+
current_fds: Dict[int, Set[Tuple[int, str]]] = {}
|
|
252
|
+
|
|
253
|
+
record_count = 0
|
|
254
|
+
for record in _run_lsof(pids):
|
|
255
|
+
record_count += 1
|
|
256
|
+
pid = record.get("pid")
|
|
257
|
+
fd = record.get("fd")
|
|
258
|
+
path = record.get("path")
|
|
259
|
+
|
|
260
|
+
if pid and fd is not None and path:
|
|
261
|
+
if pid not in current_fds:
|
|
262
|
+
current_fds[pid] = set()
|
|
263
|
+
current_fds[pid].add((fd, path))
|
|
264
|
+
|
|
265
|
+
_process_lsof_data(record, monitor, timestamp)
|
|
266
|
+
|
|
267
|
+
log.debug(f"Processed {record_count} lsof records")
|
|
268
|
+
|
|
269
|
+
# Detect closed files (present in seen_fds but not in current_fds)
|
|
270
|
+
close_count = 0
|
|
271
|
+
for pid, fd_paths in seen_fds.items():
|
|
272
|
+
current_pid_fds = current_fds.get(pid, set())
|
|
273
|
+
for fd, path in fd_paths:
|
|
274
|
+
if (fd, path) not in current_pid_fds:
|
|
275
|
+
log.debug(
|
|
276
|
+
f"Detected closed file: PID {pid}, FD {fd}, path {path}"
|
|
277
|
+
)
|
|
278
|
+
monitor.close(pid, fd, True, timestamp)
|
|
279
|
+
close_count += 1
|
|
280
|
+
|
|
281
|
+
if close_count > 0:
|
|
282
|
+
log.debug(f"Detected {close_count} closed files")
|
|
283
|
+
|
|
284
|
+
seen_fds = current_fds
|
|
285
|
+
log.debug(f"Sleeping for {poll_interval} seconds")
|
|
286
|
+
time.sleep(poll_interval)
|
|
287
|
+
|
|
288
|
+
except KeyboardInterrupt:
|
|
289
|
+
log.info("lsof monitoring interrupted by user")
|
|
290
|
+
except Exception as e:
|
|
291
|
+
log.exception(f"Unexpected error in lsof attach: {e}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def run(command: List[str], monitor: Monitor) -> None:
|
|
295
|
+
"""Run a command and monitor its open files using lsof."""
|
|
296
|
+
log.info(f"Running command: {' '.join(command)}")
|
|
297
|
+
|
|
298
|
+
proc = None
|
|
299
|
+
try:
|
|
300
|
+
log.debug("Launching subprocess")
|
|
301
|
+
proc = subprocess.Popen(command)
|
|
302
|
+
pid = proc.pid
|
|
303
|
+
log.info(f"Command started with PID: {pid}")
|
|
304
|
+
|
|
305
|
+
# Track the parent process and all its descendants
|
|
306
|
+
pids = [pid]
|
|
307
|
+
# Use a custom attach function to track descendants
|
|
308
|
+
_attach_with_descendants(pids, monitor)
|
|
309
|
+
|
|
310
|
+
except subprocess.SubprocessError as e:
|
|
311
|
+
log.error(f"Error launching command: {e}")
|
|
312
|
+
except Exception as e:
|
|
313
|
+
log.exception(f"Unexpected error in lsof run: {e}")
|
|
314
|
+
finally:
|
|
315
|
+
if proc and proc.poll() is None:
|
|
316
|
+
log.info(f"Terminating command process (PID: {proc.pid})")
|
|
317
|
+
proc.terminate()
|
|
318
|
+
try:
|
|
319
|
+
proc.wait(timeout=1)
|
|
320
|
+
log.debug(
|
|
321
|
+
f"Process terminated gracefully with exit code: {proc.returncode}"
|
|
322
|
+
)
|
|
323
|
+
except subprocess.TimeoutExpired:
|
|
324
|
+
log.debug(
|
|
325
|
+
f"Process did not terminate gracefully, killing PID {proc.pid}"
|
|
326
|
+
)
|
|
327
|
+
proc.kill()
|
|
328
|
+
proc.wait()
|
|
329
|
+
log.debug(f"Process killed with exit code: {proc.returncode}")
|
lsoph/backend/psutil.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
8
|
+
|
|
9
|
+
import psutil
|
|
10
|
+
|
|
11
|
+
from lsoph.monitor import Monitor
|
|
12
|
+
|
|
13
|
+
# Setup logging
|
|
14
|
+
log = logging.getLogger("lsoph.backend.psutil")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PsutilBackend:
|
|
18
|
+
"""Backend implementation that uses psutil to monitor file activities."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, monitor: Monitor):
|
|
21
|
+
self.monitor = monitor
|
|
22
|
+
self.cwd_map: Dict[int, str] = {}
|
|
23
|
+
self.pid_map: Dict[int, bool] = {} # Tracks if we've seen this PID before
|
|
24
|
+
self.fd_map: Dict[int, Dict[int, Tuple[str, bool, bool]]] = (
|
|
25
|
+
{}
|
|
26
|
+
) # pid -> {fd -> (path, read, write)}
|
|
27
|
+
self.poll_interval = 0.5 # seconds
|
|
28
|
+
self.running = False
|
|
29
|
+
self.poll_thread = None
|
|
30
|
+
self.lock = threading.RLock()
|
|
31
|
+
|
|
32
|
+
def _get_process_cwd(self, pid: int) -> Optional[str]:
|
|
33
|
+
"""Get current working directory for a process."""
|
|
34
|
+
try:
|
|
35
|
+
proc = psutil.Process(pid)
|
|
36
|
+
return proc.cwd()
|
|
37
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
38
|
+
return None
|
|
39
|
+
except Exception as e:
|
|
40
|
+
log.debug(f"Error getting CWD for PID {pid}: {e}")
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def _resolve_path(self, pid: int, path: str) -> str:
|
|
44
|
+
"""Resolve a potentially relative path to absolute using process CWD."""
|
|
45
|
+
if path.startswith("/") or (len(path) > 1 and path[1] == ":"): # Absolute path
|
|
46
|
+
return path
|
|
47
|
+
|
|
48
|
+
# Try to resolve using process CWD
|
|
49
|
+
if pid in self.cwd_map:
|
|
50
|
+
return os.path.normpath(os.path.join(self.cwd_map[pid], path))
|
|
51
|
+
|
|
52
|
+
# Try to get CWD if not cached
|
|
53
|
+
cwd = self._get_process_cwd(pid)
|
|
54
|
+
if cwd:
|
|
55
|
+
self.cwd_map[pid] = cwd
|
|
56
|
+
return os.path.normpath(os.path.join(cwd, path))
|
|
57
|
+
|
|
58
|
+
# Fallback to current directory
|
|
59
|
+
return os.path.abspath(path)
|
|
60
|
+
|
|
61
|
+
def _get_open_files(self, pid: int) -> List[Dict[str, Any]]:
|
|
62
|
+
"""Get open files for a process with error handling."""
|
|
63
|
+
try:
|
|
64
|
+
proc = psutil.Process(pid)
|
|
65
|
+
# Cache the CWD
|
|
66
|
+
if pid not in self.cwd_map:
|
|
67
|
+
try:
|
|
68
|
+
self.cwd_map[pid] = proc.cwd()
|
|
69
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
open_files = []
|
|
73
|
+
# Get open files directly from psutil
|
|
74
|
+
try:
|
|
75
|
+
for f in proc.open_files():
|
|
76
|
+
open_files.append(
|
|
77
|
+
{
|
|
78
|
+
"path": f.path,
|
|
79
|
+
"fd": f.fd,
|
|
80
|
+
"mode": getattr(f, "mode", ""),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# Add connections as files too
|
|
87
|
+
try:
|
|
88
|
+
for conn in proc.connections(kind="all"):
|
|
89
|
+
if conn.status == "ESTABLISHED":
|
|
90
|
+
path = f"<SOCKET:{conn.laddr.ip}:{conn.laddr.port}->{conn.raddr.ip}:{conn.raddr.port}>"
|
|
91
|
+
else:
|
|
92
|
+
path = f"<SOCKET:{conn.laddr.ip}:{conn.laddr.port}>"
|
|
93
|
+
|
|
94
|
+
open_files.append(
|
|
95
|
+
{
|
|
96
|
+
"path": path,
|
|
97
|
+
"fd": conn.fd if hasattr(conn, "fd") else -1,
|
|
98
|
+
"mode": "rw", # Assuming socket connections are read-write
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
return open_files
|
|
105
|
+
except psutil.NoSuchProcess:
|
|
106
|
+
log.debug(f"Process {pid} no longer exists.")
|
|
107
|
+
return []
|
|
108
|
+
except psutil.AccessDenied:
|
|
109
|
+
log.debug(f"Access denied for process {pid}.")
|
|
110
|
+
return []
|
|
111
|
+
except Exception as e:
|
|
112
|
+
log.debug(f"Error getting open files for PID {pid}: {e}")
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
def _get_descendants(self, pid: int) -> List[int]:
|
|
116
|
+
"""Get all descendant PIDs recursively."""
|
|
117
|
+
try:
|
|
118
|
+
proc = psutil.Process(pid)
|
|
119
|
+
return [p.pid for p in proc.children(recursive=True)]
|
|
120
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
121
|
+
return []
|
|
122
|
+
except Exception as e:
|
|
123
|
+
log.debug(f"Error getting descendants for PID {pid}: {e}")
|
|
124
|
+
return []
|
|
125
|
+
|
|
126
|
+
def _poll_processes(self, pids: List[int]) -> None:
|
|
127
|
+
"""Poll processes for open files and update monitor."""
|
|
128
|
+
with self.lock:
|
|
129
|
+
# Check for new processes and store their CWD
|
|
130
|
+
all_pids = set(pids)
|
|
131
|
+
for pid in pids:
|
|
132
|
+
descendants = self._get_descendants(pid)
|
|
133
|
+
for child_pid in descendants:
|
|
134
|
+
if child_pid not in self.pid_map:
|
|
135
|
+
# New process - inherit parent's CWD if available
|
|
136
|
+
if pid in self.cwd_map and child_pid not in self.cwd_map:
|
|
137
|
+
self.cwd_map[child_pid] = self.cwd_map[pid]
|
|
138
|
+
self.pid_map[child_pid] = True
|
|
139
|
+
log.debug(f"Found new child process: {child_pid}")
|
|
140
|
+
all_pids.add(child_pid)
|
|
141
|
+
|
|
142
|
+
# Keep track of current FDs to detect closures
|
|
143
|
+
current_fds: Dict[int, Set[int]] = {}
|
|
144
|
+
timestamp = time.time()
|
|
145
|
+
|
|
146
|
+
# Process each PID
|
|
147
|
+
for pid in all_pids:
|
|
148
|
+
# Skip processes we already know don't exist
|
|
149
|
+
if pid in self.pid_map and self.pid_map[pid] is False:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
# Get open files
|
|
153
|
+
open_files = self._get_open_files(pid)
|
|
154
|
+
|
|
155
|
+
# If process doesn't exist, mark it
|
|
156
|
+
if not open_files and not psutil.pid_exists(pid):
|
|
157
|
+
self.pid_map[pid] = False
|
|
158
|
+
if pid in self.fd_map:
|
|
159
|
+
# Process exit - close all its files
|
|
160
|
+
for fd in list(self.fd_map[pid].keys()):
|
|
161
|
+
path, _, _ = self.fd_map[pid][fd]
|
|
162
|
+
self.monitor.close(pid, fd, True, timestamp)
|
|
163
|
+
del self.fd_map[pid]
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
current_fds[pid] = set()
|
|
167
|
+
|
|
168
|
+
# Process open files
|
|
169
|
+
for file_info in open_files:
|
|
170
|
+
path = file_info["path"]
|
|
171
|
+
fd = file_info["fd"]
|
|
172
|
+
mode = file_info.get("mode", "")
|
|
173
|
+
|
|
174
|
+
# Skip if invalid fd
|
|
175
|
+
if fd < 0:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
current_fds[pid].add(fd)
|
|
179
|
+
|
|
180
|
+
# Check if this is a new file or changed mode
|
|
181
|
+
is_new = True
|
|
182
|
+
has_changed = False
|
|
183
|
+
if pid in self.fd_map and fd in self.fd_map[pid]:
|
|
184
|
+
is_new = False
|
|
185
|
+
old_path, old_read, old_write = self.fd_map[pid][fd]
|
|
186
|
+
has_changed = old_path != path
|
|
187
|
+
|
|
188
|
+
# Handle new file or changed file
|
|
189
|
+
if is_new or has_changed:
|
|
190
|
+
if not is_new and has_changed:
|
|
191
|
+
# File path changed (might be a rename)
|
|
192
|
+
old_path, _, _ = self.fd_map[pid][fd]
|
|
193
|
+
# First close the old file
|
|
194
|
+
self.monitor.close(pid, fd, True, timestamp)
|
|
195
|
+
# Then open the new one
|
|
196
|
+
log.debug(
|
|
197
|
+
f"File path changed for PID {pid}, FD {fd}: {old_path} -> {path}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Register file open
|
|
201
|
+
can_read = "r" in mode
|
|
202
|
+
can_write = "w" in mode or "a" in mode or "+" in mode
|
|
203
|
+
|
|
204
|
+
self.monitor.open(pid, path, fd, True, timestamp)
|
|
205
|
+
self.fd_map.setdefault(pid, {})[fd] = (
|
|
206
|
+
path,
|
|
207
|
+
can_read,
|
|
208
|
+
can_write,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Register read/write access based on mode
|
|
212
|
+
if can_read:
|
|
213
|
+
self.monitor.read(pid, fd, path, True, timestamp, bytes=0)
|
|
214
|
+
if can_write:
|
|
215
|
+
self.monitor.write(pid, fd, path, True, timestamp, bytes=0)
|
|
216
|
+
|
|
217
|
+
# Detect closed files
|
|
218
|
+
for pid in list(self.fd_map.keys()):
|
|
219
|
+
if pid not in current_fds:
|
|
220
|
+
if psutil.pid_exists(pid):
|
|
221
|
+
# Process exists but has no open files
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# Process has terminated - close all its files
|
|
225
|
+
for fd, (path, _, _) in self.fd_map[pid].items():
|
|
226
|
+
self.monitor.close(pid, fd, True, timestamp)
|
|
227
|
+
del self.fd_map[pid]
|
|
228
|
+
else:
|
|
229
|
+
# Check for closed files in existing processes
|
|
230
|
+
for fd in list(self.fd_map[pid].keys()):
|
|
231
|
+
if fd not in current_fds[pid]:
|
|
232
|
+
path, _, _ = self.fd_map[pid][fd]
|
|
233
|
+
self.monitor.close(pid, fd, True, timestamp)
|
|
234
|
+
del self.fd_map[pid][fd]
|
|
235
|
+
|
|
236
|
+
def _polling_thread(self, pids: List[int]) -> None:
|
|
237
|
+
"""Background thread that periodically polls processes."""
|
|
238
|
+
try:
|
|
239
|
+
log.info(f"Starting polling thread for PIDs: {pids}")
|
|
240
|
+
|
|
241
|
+
while self.running:
|
|
242
|
+
try:
|
|
243
|
+
self._poll_processes(pids)
|
|
244
|
+
time.sleep(self.poll_interval)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
log.error(f"Error in polling thread: {e}")
|
|
247
|
+
time.sleep(self.poll_interval)
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
log.exception(f"Polling thread failed: {e}")
|
|
251
|
+
finally:
|
|
252
|
+
log.info("Polling thread stopped.")
|
|
253
|
+
|
|
254
|
+
def attach(self, pids: List[int], monitor: Monitor) -> None:
|
|
255
|
+
"""Attach to existing processes."""
|
|
256
|
+
self.monitor = monitor
|
|
257
|
+
self.running = True
|
|
258
|
+
|
|
259
|
+
# Initialize pid_map with starting PIDs
|
|
260
|
+
for pid in pids:
|
|
261
|
+
self.pid_map[pid] = True
|
|
262
|
+
|
|
263
|
+
# Try to get CWD for each process
|
|
264
|
+
cwd = self._get_process_cwd(pid)
|
|
265
|
+
if cwd:
|
|
266
|
+
self.cwd_map[pid] = cwd
|
|
267
|
+
|
|
268
|
+
# Start polling thread
|
|
269
|
+
self.poll_thread = threading.Thread(
|
|
270
|
+
target=self._polling_thread, args=(pids,), daemon=True
|
|
271
|
+
)
|
|
272
|
+
self.poll_thread.start()
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
while self.running:
|
|
276
|
+
time.sleep(0.1) # Keep main thread alive
|
|
277
|
+
except KeyboardInterrupt:
|
|
278
|
+
log.info("Attach interrupted by user.")
|
|
279
|
+
self.running = False
|
|
280
|
+
except Exception as e:
|
|
281
|
+
log.exception(f"Error in attach: {e}")
|
|
282
|
+
self.running = False
|
|
283
|
+
|
|
284
|
+
def run(self, command: List[str], monitor: Monitor) -> None:
|
|
285
|
+
"""Run a command and monitor its file activity."""
|
|
286
|
+
import subprocess
|
|
287
|
+
|
|
288
|
+
self.monitor = monitor
|
|
289
|
+
self.running = True
|
|
290
|
+
|
|
291
|
+
proc = None
|
|
292
|
+
try:
|
|
293
|
+
# Start the process
|
|
294
|
+
log.info(f"Running command: {' '.join(command)}")
|
|
295
|
+
proc = subprocess.Popen(command)
|
|
296
|
+
pid = proc.pid
|
|
297
|
+
log.info(f"Command started with PID: {pid}")
|
|
298
|
+
|
|
299
|
+
# Get initial CWD
|
|
300
|
+
cwd = self._get_process_cwd(pid)
|
|
301
|
+
if cwd:
|
|
302
|
+
self.cwd_map[pid] = cwd
|
|
303
|
+
|
|
304
|
+
# Mark this process as active
|
|
305
|
+
self.pid_map[pid] = True
|
|
306
|
+
|
|
307
|
+
# Start polling thread for this process and its descendants
|
|
308
|
+
self.poll_thread = threading.Thread(
|
|
309
|
+
target=self._polling_thread, args=([pid],), daemon=True
|
|
310
|
+
)
|
|
311
|
+
self.poll_thread.start()
|
|
312
|
+
|
|
313
|
+
# Wait for process to complete or user interrupt
|
|
314
|
+
while self.running and proc.poll() is None:
|
|
315
|
+
time.sleep(0.1)
|
|
316
|
+
|
|
317
|
+
if proc.poll() is not None:
|
|
318
|
+
log.info(f"Command exited with code: {proc.returncode}")
|
|
319
|
+
|
|
320
|
+
except KeyboardInterrupt:
|
|
321
|
+
log.info("Run interrupted by user.")
|
|
322
|
+
self.running = False
|
|
323
|
+
except Exception as e:
|
|
324
|
+
log.exception(f"Error in run: {e}")
|
|
325
|
+
self.running = False
|
|
326
|
+
finally:
|
|
327
|
+
# Cleanup
|
|
328
|
+
if proc and proc.poll() is None:
|
|
329
|
+
log.info("Terminating command process.")
|
|
330
|
+
proc.terminate()
|
|
331
|
+
try:
|
|
332
|
+
proc.wait(timeout=1)
|
|
333
|
+
except subprocess.TimeoutExpired:
|
|
334
|
+
log.info("Forcibly killing command process.")
|
|
335
|
+
proc.kill()
|
|
336
|
+
proc.wait()
|
|
337
|
+
|
|
338
|
+
self.running = False
|
|
339
|
+
|
|
340
|
+
# Wait for polling thread to stop
|
|
341
|
+
if self.poll_thread and self.poll_thread.is_alive():
|
|
342
|
+
self.poll_thread.join(timeout=1)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Functions exposed by the backend
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def attach(pids: List[int], monitor: Monitor) -> None:
|
|
349
|
+
"""Attach to existing processes."""
|
|
350
|
+
backend = PsutilBackend(monitor)
|
|
351
|
+
backend.attach(pids, monitor)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def run(command: List[str], monitor: Monitor) -> None:
|
|
355
|
+
"""Run a command and monitor its file activity."""
|
|
356
|
+
backend = PsutilBackend(monitor)
|
|
357
|
+
backend.run(command, monitor)
|