Find the file handle leak in a log scanner
Problem statement
A monitoring job counts ERROR lines across every log file on a host, and the counts on the dashboard look too low. The function is about to be moved into a long-running agent, where leaked file handles eventually end in OSError: [Errno 24] Too many open files. Review it, list the bugs, and fix it.
count_errors.py (broken)
def count_errors(paths): total = 0 for p in paths: try: f = open(p) for line in f: if line.startswith("FATAL"): return total # nothing useful after a fatal if line.startswith("ERROR"): total += 1 except: pass return totalThe intended behaviour: count lines starting with ERROR in every file; within one file, stop at a FATAL line (anything after it is noise from the crash); report files that cannot be read instead of hiding them.
Test data (c.log is in the list but does not exist):
a.log: INFO start / ERROR disk full / FATAL giving up / ERROR after fatalb.log: ERROR timeout / INFO okc.log: (missing)d.log: ERROR one / ERROR twoThe expected count is 4: one in a.log before the FATAL, one in b.log, two in d.log. A test harness records ResourceWarnings, which Python emits when a file object is garbage-collected while still open. That makes the leak visible even on a small test.
Symptom (actual output of the broken version):
ERROR lines counted: 1files left open: ['a.log']Examples
Example 1
Input: The same harness with the fixed `count_errors`
Output: warning: skipped c.log: No such file or directory
ERROR lines counted: 4
files left open: none
Hints
Approach
Optimal
Bug 1: the file is never closed. f = open(p) has no matching close(). In CPython the file is usually closed when the variable is rebound or the function returns, because its reference count drops to zero, and that is why this code "works" in small tests. But nothing guarantees it: if a reference survives (a traceback kept in a log handler, a generator, a different Python implementation), handles pile up until the process hits its open-file limit. The harness makes the leak visible: ResourceWarning: unclosed file fires for a.log, the file that was open when return ran. Fix: with open(...) as f: closes the file on every exit path, including break, return and exceptions.
Bug 2: return instead of break. A FATAL line was meant to stop scanning that file. return stops scanning all files, so b.log and d.log are never read and the count is 1 instead of 4. That explains the low dashboard numbers.
Bug 3: bare except: pass. It hides the missing c.log, and it would equally hide a NameError in the loop, a KeyboardInterrupt, or a UnicodeDecodeError, producing a silently wrong total. Fix: catch OSError only, and print which file was skipped and why.
Bug 4: no encoding. open(p) uses the platform's default encoding. A UTF-8 log with one non-ASCII character can then raise UnicodeDecodeError on some systems (and bug 3 would hide it). Fix: encoding="utf-8", errors="replace", so an odd byte never stops the scan.
The fixed version prints the skipped file, counts 4, and leaves no files open.
O(total lines)Space O(1)import os def count_errors(paths): total = 0 for p in paths: try: with open(p, encoding="utf-8", errors="replace") as f: for line in f: if line.startswith("FATAL"): break # stop this file, keep scanning the others if line.startswith("ERROR"): total += 1 except OSError as e: print(f"warning: skipped {os.path.basename(p)}: {e.strerror}") return total # ---- test harness: sample logs, and catch "unclosed file" warnings ----import gcimport osimport tempfileimport warnings LOGS = { "a.log": "INFO start\nERROR disk full\nFATAL giving up\nERROR after fatal\n", "b.log": "ERROR timeout\nINFO ok\n", "c.log": None, # listed but missing "d.log": "ERROR one\nERROR two\n",} with tempfile.TemporaryDirectory() as d: paths = [] for name, text in LOGS.items(): p = os.path.join(d, name) paths.append(p) if text is not None: with open(p, "w", encoding="utf-8") as f: f.write(text) with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always", ResourceWarning) total = count_errors(paths) gc.collect() leaked = sorted(os.path.basename(str(w.message).split("name='")[1].split("'")[0]) for w in caught if issubclass(w.category, ResourceWarning)) print(f"ERROR lines counted: {total}") print(f"files left open: {leaked if leaked else 'none'}")Follow-up questions
- Also read
.gzrotated logs withgzip.open, still inside awithblock. - Process the files in parallel with a thread pool. What limits the number of open files now?
- Return per-file counts as well as the total, so the dashboard can show which log is noisy.
Frequently asked questions
Long-running agents and cron jobs that scan many files are exactly where leaked handles turn into Too many open files, often weeks after the code shipped. Reviewers are expected to spot a missing with block and an over-broad except on sight.
Yes. Relying on garbage collection makes closing depend on details you do not control: whether anything else holds a reference, whether the code runs on PyPy, and when the collector runs. with makes it deterministic, and it also flushes and closes files you write, which matters for data integrity, not just handle counts.
Count the open handles: ls /proc/<pid>/fd | wc -l or lsof -p <pid> on Linux, and watch it grow over time. In tests, run with python -X dev or -W error::ResourceWarning so unclosed files are reported or become errors.