GTC Berlin, October 21, 2026. NVIDIA announced that Isaac Lab 3.0 is generally available, describing it as a framework for training robot policies "from desktop to data center." Three days later I was at the desktop end of that phrase. My desktop was a Windows 11 laptop, and two of Isaac Lab's logging tests were failing on it with an error that had nothing to do with robots.
Isaac Lab is NVIDIA's open-source framework for training robot policies in GPU-accelerated simulation. Version 3.0 supports the Newton and PhysX physics engines, and the v3.0.0 release notes make Isaac Sim optional for many workflows. This post is about a side quest on that laptop and the one-line fix it produced, now merged as PR #8809.
The short version
A logging handler is the thing that keeps a log file open and writes lines into it. When Isaac Lab reconfigured its logging, it unhooked the old handlers from the logger but never closed them, so the old log file stayed open in the background. Linux lets you delete a file that is still open, so nobody noticed. Windows refuses, so two tests failed on my machine. The fix is one line: close what you unhook.
The concept, animated
Figure 1. Every part carries its real name. The board is the root logger's handlers list. The wire from a FileHandler to its .log file is the handler's .stream, and the lamp reads "stream open" for as long as that wire exists. The three states are the three calls, in order: configure_logging(file=...), removeHandler(old), and the fix, handler.close().
Watch the second state. removeHandler() does exactly what its name says: the old handler leaves the list, so the logger will never call it again. Nothing about that call touches the wire. The old FileHandler still owns an open stream to isaaclab_1.log, and that is what Windows checks when os.remove() arrives: something holds the file, so the delete fails with PermissionError: [WinError 32]. Linux lets a delete go through on an open file, which is why the leak was invisible everywhere except Windows.
Figure 2. The third state: handler.close() ends the stream, the lamp goes out, and the same os.remove() succeeds. The new handler and its file are untouched.
How I got there
I came to Isaac Lab for a different bug. Issue #8534 reported that a contrib humanoid task passed wxyz quaternions into xyzw APIs, so the humanoid trained upside down. I wanted that one. Another contributor had already claimed it and opened a PR.
So I took the fallback on my list. Isaac Lab's docs list Windows 11 as a supported platform, but when I checked in mid-October, no CI job ran its tests on Windows and the windows_ci test marker was applied to zero test files. I work on Windows, so that gap was mine to poke at.
My setup was plain: Windows 11, Python 3.12.12 and CPU-only PyTorch 2.11.0, the same Python and PyTorch versions 3.0 targets, minus CUDA. I did not install Isaac Sim, so the real work was sorting failures by cause.
I ran every unit test in four test folders on main and got 913 passed, 72 failed, 125 skipped and 5 collection errors. Most of the failures were my environment talking: a missing omni module (that is Isaac Sim), a CPU-only torch build, no torchvision. Others were Windows symlink permission errors, because Developer Mode was off, and a few tests with POSIX assumptions about Isaac Sim build trees. I set those aside, since judging them fairly needs Developer Mode or a simulator build.
Two failures in source/isaaclab/test/utils/test_logger.py did not fit any of those buckets.
Why only Windows complained
Both tests failed with PermissionError: [WinError 32]. One failed while its temporary directory was being cleaned up, because the log file inside could not be removed. The other failed on its own os.remove call. These tests are plain Python and run without a simulator or a GPU, so my setup could not explain them.
WinError 32 is Windows saying that some process still has the file open. The only thing that opens that log file is the file handler installed by configure_logging, so I read what that function does with the handlers it replaces. Then I wrote a short probe: configure logging with a file, reconfigure without one, and look at the old handler. Its stream was still an open file. The log file had outlived the configuration that created it.
That is the whole mechanism in figure 1. removeHandler() detaches a handler from a logger and leaves it open. A detached file handler keeps its file until someone calls close(), and nobody did. On Linux the leak is invisible, because an open file can still be deleted. On Windows it is a locked file.
The fix, in one idea
Close what you remove. The loop that unhooks each old handler now also closes it:
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
handler.close()
That is the entire code change. Python's own logging.basicConfig(force=True) already removes and closes existing handlers the same way, so the fix brings Isaac Lab in line with the standard library, and the docstring now says so.
The tests changed too. Both now reconfigure logging before cleanup, which releases the file, and one of them asserts that the replaced handler's stream is gone. That assertion checks the handle, not the Windows error, so a Linux CI run can catch the regression too. I only ran it on Windows, where it failed against the old logger.py.
What changed for Isaac Lab users
Any process that calls configure_logging more than once, such as a test session, now releases the previous log file right away instead of holding it until exit. On Windows, those files can be deleted again.
Against main at commit 4a281f3, with the PR's tests, test_logger.py had 2 failures and 12 passes. With the fix, all 14 pass. Across the source/isaaclab/test/utils unit sweep, the same 36 environment failures appeared with and without the change, so the fix moved nothing else.
I flagged one trade-off in the PR. The function now closes every handler it removes, including ones it did not create, such as Kit log bridges or pytest's --log-file handler. The old code had already detached them. Closing a plain stream handler leaves its underlying stream open, so only file-backed handlers behave differently. A maintainer could reasonably prefer closing only the handlers the function made, and I wanted that choice in front of them.
How a first PR got merged in 28 hours
The contributing guide asks for an issue first, so I filed #8808 and then opened #8809 with "Fixes #8808". The repo wanted a changelog fragment instead of a CHANGELOG.rst edit, plus a line in CONTRIBUTORS.md. Isaac Lab's main moves fast, with 587 PRs merged in the 30 days before I started, so I rebased from 4a281f3 onto 259e1a8 before pushing. Both files I touched were identical at the two commits, so my Windows baseline still held.
Three minutes after I opened the PR, the repo's review bot asked for two changes. First, make the cleanup in the file-handler test exception-safe: if an assertion failed halfway, the file would stay open and the cleanup would raise its own PermissionError, hiding the real failure. I moved the reconfiguring call into a finally block. Second, name the tested baseline revision and give explicit failure counts. I rewrote the Testing section with the numbers above.
Then the maintainers took over. AntoineRichard commented run-ci to start the Docker and GPU test jobs, approved the PR, and merged it on October 26, about 28 hours after I opened it. maxkra15 gave the second approval.
Thank you
Thank you to AntoineRichard for the review, the CI run and the merge, and to maxkra15 for the second review. Thanks also to the Isaac Lab maintainers for a contribution path I could follow end to end from the docs, with an issue template, a PR checklist, changelog fragments, and a review bot that caught my gaps before a human had to.
What I took away
Windows is where this bug showed up, but the open file exists on every OS. So the regression test checks the handle instead of the Windows error, and a failure that only Windows could see became a check any CI can run. When a bug appears on one platform, I look for the platform-independent cause and pin the test to that.
The second lesson is about triage. Two of the 72 failures turned into a fix. Getting there meant sorting the other 70 by cause first and writing down why I left each group alone.
Code that removes something holding a file should close it too, because Linux hides the leak that Windows reports. Isaac Lab 3.0 promises desktop to data center, and running its tests on a platform its CI did not cover was a cheap way to find a real bug.
I'm open to roles in robot learning, ML systems and MLOps. You can find my work at shreyaboyane.dev, my code on GitHub as Sonica-B, and me on LinkedIn.
![Animated figure. The root logger drawn as a board holding a StreamHandler and a FileHandler; each FileHandler is wired to its log file, whose lamp reads "stream open". removeHandler lifts the old handler off the board with its wire still attached and os.remove reports PermissionError [WinError 32]; after handler.close the wire is gone, the lamp reads "closed" and os.remove reports "deleted".](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvf1pzvxjqi0aj5nn9gyp.gif)

Top comments (0)