1 /* 2 * Copyright (C) 2020, 2021, 2022 Vladimir Panteleev <btdu@cy.md> 3 * 4 * This program is free software; you can redistribute it and/or 5 * modify it under the terms of the GNU General Public 6 * License v2 as published by the Free Software Foundation. 7 * 8 * This program is distributed in the hope that it will be useful, 9 * but WITHOUT ANY WARRANTY; without even the implied warranty of 10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 11 * General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public 14 * License along with this program; if not, write to the 15 * Free Software Foundation, Inc., 59 Temple Place - Suite 330, 16 * Boston, MA 021110-1307, USA. 17 */ 18 19 /// btdu entry point 20 module btdu.main; 21 22 import core.lifetime : move; 23 import core.runtime : Runtime; 24 import core.time; 25 26 import std.conv : to; 27 import std.exception; 28 import std.parallelism : totalCPUs; 29 import std.path; 30 import std.random; 31 import std.socket; 32 import std.stdio; 33 import std.string; 34 35 import ae.sys.data; 36 import ae.sys.datamm; 37 import ae.sys.file : getPathMountInfo; 38 import ae.sys.shutdown; 39 import ae.utils.funopt; 40 import ae.utils.json; 41 import ae.utils.main; 42 import ae.utils.time.parsedur; 43 44 import btdu.browser; 45 import btdu.common; 46 import btdu.paths; 47 import btdu.sample; 48 import btdu.subproc; 49 import btdu.state; 50 51 @(`Sampling disk usage profiler for btrfs.`) 52 void program( 53 Parameter!(string, "Path to the root of the filesystem to analyze") path, 54 Option!(uint, "Number of sampling subprocesses\n (default is number of logical CPUs for this system)", "N", 'j') procs = 0, 55 Option!(Seed, "Random seed used to choose samples") seed = 0, 56 Switch!hiddenOption subprocess = false, 57 Switch!("Expert mode: collect and show additional metrics.\nUses more memory.") expert = false, 58 Switch!hiddenOption man = false, 59 Switch!("Run without launching the result browser UI.") headless = false, 60 Option!(ulong, "Stop after collecting N samples.", "N", 'n') maxSamples = 0, 61 Option!(string, "Stop after running for this duration.", "DURATION") maxTime = null, 62 Option!(string, "Stop after achieving this resolution.", "SIZE") minResolution = null, 63 Option!(string, "On exit, export the collected results to the given file.", "PATH", 'o', "export") exportPath = null, 64 Switch!("Instead of analyzing a btrfs filesystem, read previously collected results saved with --export from PATH.", 'f', "import") doImport = false, 65 ) 66 { 67 if (man) 68 { 69 stdout.write(generateManPage!program( 70 "btdu", 71 ".B btdu 72 is a sampling disk usage profiler for btrfs. 73 74 For a detailed description, please see the full documentation: 75 76 .I https://github.com/CyberShadow/btdu#readme", 77 null, 78 `.SH BUGS 79 Please report defects and enhancement requests to the GitHub issue tracker: 80 81 .I https://github.com/CyberShadow/btdu/issues 82 83 .SH AUTHORS 84 85 \fBbtdu\fR is written by Vladimir Panteleev <btdu@c\fRy.m\fRd> and contributors: 86 87 .I https://github.com/CyberShadow/btdu/graphs/contributors 88 `, 89 )); 90 return; 91 } 92 93 Data importData; // Keep memory-mapped file alive, as directory names may reference it 94 if (doImport) 95 { 96 if (procs || seed || subprocess || expert || headless || maxSamples || maxTime || minResolution || exportPath) 97 throw new Exception("Conflicting command-line options"); 98 99 stderr.writeln("Loading results from file..."); 100 importData = mapFile(path, MmMode.read); 101 auto json = cast(string)importData.contents; 102 103 debug importing = true; 104 auto s = json.jsonParse!SerializedState(); 105 106 expert = s.expert; 107 fsPath = s.fsPath; 108 totalSize = s.totalSize; 109 move(*s.root, browserRoot); 110 111 browserRoot.resetParents(); 112 debug importing = false; 113 imported = true; 114 } 115 else 116 { 117 rndGen = Random(seed); 118 fsPath = path.buildNormalizedPath; 119 120 if (subprocess) 121 return subprocessMain(path); 122 123 checkBtrfs(fsPath); 124 125 if (procs == 0) 126 procs = totalCPUs; 127 128 subprocesses = new Subprocess[procs]; 129 foreach (ref subproc; subprocesses) 130 subproc.start(); 131 } 132 133 Duration parsedMaxTime; 134 if (maxTime) 135 parsedMaxTime = parseDuration(maxTime); 136 137 real parsedMinResolution; 138 if (minResolution) 139 parsedMinResolution = parseSize(minResolution); 140 141 Socket stdinSocket; 142 if (!headless) 143 { 144 stdinSocket = new Socket(cast(socket_t)stdin.fileno, AddressFamily.UNSPEC); 145 stdinSocket.blocking = false; 146 } 147 148 .expert = expert; 149 150 Browser browser; 151 if (!headless) 152 { 153 browser.start(); 154 browser.update(); 155 } 156 157 auto startTime = MonoTime.currTime(); 158 enum refreshInterval = 500.msecs; 159 auto nextRefresh = startTime; 160 161 auto readSet = new SocketSet; 162 auto exceptSet = new SocketSet; 163 164 bool run = true; 165 if (headless) // In non-headless mode, ncurses takes care of this 166 addShutdownHandler((reason) { 167 run = false; 168 }); 169 170 // Main event loop 171 while (run) 172 { 173 readSet.reset(); 174 exceptSet.reset(); 175 if (stdinSocket) 176 { 177 readSet.add(stdinSocket); 178 exceptSet.add(stdinSocket); 179 } 180 if (!paused) 181 foreach (ref subproc; subprocesses) 182 readSet.add(subproc.socket); 183 184 if (!headless && browser.needRefresh()) 185 Socket.select(readSet, null, exceptSet, refreshInterval); 186 else 187 Socket.select(readSet, null, exceptSet); 188 auto now = MonoTime.currTime(); 189 190 if (stdinSocket && browser.handleInput()) 191 { 192 do {} while (browser.handleInput()); // Process all input 193 if (browser.done) 194 break; 195 browser.update(); 196 nextRefresh = now + refreshInterval; 197 } 198 if (!paused) 199 foreach (ref subproc; subprocesses) 200 if (readSet.isSet(subproc.socket)) 201 subproc.handleInput(); 202 if (!headless && now > nextRefresh) 203 { 204 browser.update(); 205 nextRefresh = now + refreshInterval; 206 } 207 208 if ((maxSamples && browserRoot.data[SampleType.represented].samples >= maxSamples) || 209 (maxTime && now > startTime + parsedMaxTime) || 210 (minResolution && (totalSize / browserRoot.data[SampleType.represented].samples) <= parsedMinResolution)) 211 { 212 if (headless) 213 break; 214 else 215 { 216 if (!paused) 217 { 218 browser.togglePause(); 219 browser.update(); 220 } 221 // Only pause once 222 maxSamples = 0; 223 maxTime = minResolution = null; 224 } 225 } 226 } 227 228 if (headless) 229 { 230 auto totalSamples = browserRoot.data[SampleType.represented].samples; 231 stderr.writefln( 232 "Collected %s samples (achieving a resolution of ~%s) in %s.", 233 totalSamples, 234 totalSamples ? (totalSize / totalSamples).humanSize() : "-", 235 MonoTime.currTime() - startTime, 236 ); 237 } 238 239 if (exportPath) 240 { 241 stderr.writeln("Exporting results..."); 242 243 SerializedState s; 244 s.expert = expert; 245 s.fsPath = fsPath; 246 s.totalSize = totalSize; 247 s.root = &browserRoot; 248 249 alias LockingBinaryWriter = typeof(File.lockingBinaryWriter()); 250 alias JsonFileSerializer = CustomJsonSerializer!(JsonWriter!LockingBinaryWriter); 251 252 { 253 JsonFileSerializer j; 254 auto file = exportPath == "-" ? stdout : File(exportPath, "wb"); 255 j.writer.output = file.lockingBinaryWriter; 256 j.put(s); 257 } 258 stderr.writeln("Exported results to: ", exportPath); 259 } 260 } 261 262 /// Serialized 263 struct SerializedState 264 { 265 bool expert; 266 string fsPath; 267 ulong totalSize; 268 BrowserPath* root; 269 } 270 271 void checkBtrfs(string fsPath) 272 { 273 import core.sys.posix.fcntl : open, O_RDONLY; 274 import std.string : toStringz; 275 import btrfs : isBTRFS, isSubvolume, getSubvolumeID; 276 import btrfs.c.kernel_shared.ctree : BTRFS_FS_TREE_OBJECTID; 277 278 int fd = open(fsPath.toStringz, O_RDONLY); 279 errnoEnforce(fd >= 0, "open"); 280 281 enforce(fd.isBTRFS, 282 fsPath ~ " is not a btrfs filesystem"); 283 284 enforce(fd.isSubvolume, { 285 auto rootPath = getPathMountInfo(fsPath).file; 286 if (!rootPath) 287 rootPath = "/"; 288 return format( 289 "%s is not the root of a btrfs subvolume - " ~ 290 "please specify the path to the subvolume root" ~ 291 "\n" ~ 292 "E.g.: %s", 293 fsPath, 294 [Runtime.args[0], rootPath].escapeShellCommand, 295 ); 296 }()); 297 298 enforce(fd.getSubvolumeID() == BTRFS_FS_TREE_OBJECTID, { 299 auto device = getPathMountInfo(fsPath).spec; 300 if (!device) 301 device = "/dev/sda1"; // placeholder 302 auto tmpName = "/tmp/" ~ device.baseName; 303 return format( 304 "%s is not the root btrfs subvolume - " ~ 305 "please specify the path to a mountpoint mounted with subvol=/ or subvolid=5" ~ 306 "\n" ~ 307 "E.g.: %s && %s && %s", 308 fsPath, 309 ["mkdir", tmpName].escapeShellCommand, 310 ["mount", "-o", "subvol=/", device, tmpName].escapeShellCommand, 311 [Runtime.args[0], tmpName].escapeShellCommand, 312 ); 313 }()); 314 } 315 316 private string escapeShellCommand(string[] args) 317 { 318 import std.process : escapeShellFileName; 319 import std.algorithm.searching : all; 320 import ae.utils.array : isOneOf; 321 322 foreach (ref arg; args) 323 if (!arg.representation.all!(c => c.isOneOf("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/.=:@%"))) 324 arg = arg.escapeShellFileName; 325 return args.join(" "); 326 } 327 328 void usageFun(string usage) 329 { 330 stderr.writeln("btdu v" ~ btduVersion); 331 stderr.writeln(usage); 332 } 333 334 mixin main!(funopt!(program, FunOptConfig.init, usageFun));