Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix leak of launcher on disconnect #117

Merged
merged 1 commit into from Jun 9, 2020

Conversation

justfalter
Copy link
Contributor

@justfalter justfalter commented May 20, 2020

I have found that the vSphere Cloud plugin does not always tear down the underlying launcher when it is tearing down its build slaves. In my configuration, this leads to a leak of SSH sockets.
My environment makes almost exclusive use of run-once vSphere templates using the SSH launch agent, executing ~2000 per day. We've noticed that our Jenkins instance will accumulate ESTABLISHED TCP sockets to SSH services on vSphere hosts at a rate of ~1000 per week. Over time, this can lead to an exhaustion of file handles.

A simple reproduction involves:

  1. Create a vSphere template that:
    • Uses an SSH Launch agent
    • Configured for the Run-Once strategy
    • Number of Executors: 1
    • Disconnect After Limited Builds: 1
  2. Create a pipeline job that spins up 10 of these nodes in parallel:
def parallelJobs = [:]

10.times {
    def c = it
    parallelJobs["job${c}"] = {
        node("centos7") {
            echo "Hello World: ${c}"
        }
    }
}

parallel(parallelJobs)
  1. Launch the pipeline several times in a row.

Running lsof on against the jenkins process (ex: 12345) shows an accumulation of SSH sockets for build slaves that no longer exist:

$ lsof -n -P -p 12345|grep TCP
....
java    3309 jenkins 1852u     IPv4         1373870262       0t0        TCP 10.1.1.2:49784->10.2.1.229:22 (ESTABLISHED)
java    3309 jenkins 1862u     IPv4         1319015182       0t0        TCP 10.1.1.2:58112->10.2.1.55:22 (ESTABLISHED)
java    3309 jenkins 1888u     IPv4         1373865499       0t0        TCP 10.1.1.2:35440->10.2.1.230:22 (ESTABLISHED)
java    3309 jenkins 1922u     IPv4         1359700779       0t0        TCP 10.1.1.2:48134->10.2.1.9:22 (ESTABLISHED)
java    3309 jenkins 1939u     IPv4         1360881203       0t0        TCP 10.1.1.2:38708->10.2.1.104:22 (ESTABLISHED)
java    3309 jenkins 1943u     IPv4         1360808902       0t0        TCP 10.1.1.2:37400->10.2.1.90:22 (ESTABLISHED)
java    3309 jenkins 2003u     IPv4         1359902180       0t0        TCP 10.1.1.2:52876->10.2.1.245:22 (ESTABLISHED)

....

Cause

vSphereCloudLauncher.afterDisconnect would not call through to the underlying launcher's afterDisconnect when slaveComputer.getNode() returned null. This is problematic because SSHLauncher's teardown of the socket relies on its afterDisconnect being called.

According to the documentation for afterDisconnect:

Disconnect operation is performed asynchronously, so there's no guarantee that the corresponding SlaveComputer exists for the duration of the operation.

I found it helpful to implement a NodeListener that would let me know when the node was deleted. In cases where the leak occurs, I would see a NODE DELETED: mynode1 message, followed by a Slave is null.

Fix

Modify vSphereCloudLauncher.afterDisconnect so that it calls through to the launcher's afterDisconnect even though slaveComputer.getNode() may have returned null.

Caveats

  • There is a lot going on in vSphereCloudLauncher.afterDisconnect, and I am unable to confirm that the changes I've made affect those code paths. Almost none of it is in the code path for my environment (our vsphere templates always seem to have an idle action of NOTHING).
  • afterDisconnect will be called multiple times --- I don't know if this is problematic for the idle action code. If it is, then it might be a good idea to move that code outside of afterDisconnect..

- afterDisconnect is called asynchronously from the deletion of the
Node. Ensure that we continue to process in the event that
`slaveComputer.getNode()` returns null, as we still have to ensure that
the launcher is torn down. Without this, SSHLauncher could sometimes
leak sockets.
@justfalter
Copy link
Contributor Author

@pjdarton Any thoughts on this PR?

@pjdarton
Copy link
Member

It sounds plausible, it looks like you've done your homework and, at a (very) brief glance, I don't see anything objectionable in the code changes.

However, while I can spare time to press the "merge" button, I don't have enough spare time to test this myself (at all - 110% busy on other stuff) .... so, if you want this merged, you're going to have to test it yourself.

I agree that, yes, there is "a lot going on" in that code (I don't properly understand it, and there's nothing noteworthy that'll unit-test it). This code pre-dates my involvement with this plugin, and (I believe) pre-dates Jenkins having "cloud" support at all - its complexity results from making Jenkins do cloud-like functionality using statically provisioned slaves ... and so that's what needs testing (as well as testing that your code changes achieve the effect you want too) before we can release this.
i.e. tell me how you've tested that you've not broken the legacy (complex) behaviour and then I'll merge it.

@justfalter
Copy link
Contributor Author

However, while I can spare time to press the "merge" button, I don't have enough spare time to test this myself (at all - 110% busy on other stuff) .... so, if you want this merged, you're going to have to test it yourself.
i.e. tell me how you've tested that you've not broken the legacy (complex) behaviour and then I'll merge it.

Fair enough! I was hoping that you (or someone) had a lab that was ready-to-go. I'll get on this.

@pjdarton
Copy link
Member

I have a server room busy running builds - it's not a server room laying idle awaiting vSphere plugin testing :-)
If I had the time spare, I could babysit testing a new plugin without interrupting my fellow developers work but, right now, I can't afford to spend time fixing anything I might break if I start fiddling ... that's why I'm asking you to test it and I'll just have to trust you not to mess it up :-)

@justfalter
Copy link
Contributor Author

No worries. I’ll do my best to put it through the wringer and, if possible, add unit tests. It might be best to extract out the bulk of the code contained in afterDisconnect, just to get it into a place where it’s easier to test.

@justfalter
Copy link
Contributor Author

FYI, I'm still working on this.

However, while trying to explore the various idle action code paths, I've run into some questions and a bug.

I can't see any practical way that anyone would use idle actions. Am I missing something?

  • There is no way to specify an idle action when configuring a vSphere Cloud Template-based build node. The default idle action for any vSphere Cloud template node is NOTHING.
    • Unless I'm missing something, this is the only means with which I may cause the vSphere Cloud plugin to create a Jenkins build node. I see no option for manually creating build nodes through the UI nor through the available vSphere build step.
  • A build node that was started from a vSphere Cloud Template may be re-configured to have an idle action.
  • Are people manually reconfiguring the idle action on build nodes after they have been spun up? This seems like a very strange approach to me.
  • As an aside, I've discovered that reconfiguring one a Jenkins build node causes a bug where the original delegated launcher is lost (and effectively leaked). I'm working on chasing this down, at the moment.

Bug when reconfiguring a Jenkins build node started by the vSphere Cloud plugin

My changes have exposed a bug in the handling of reconfiguring a build node that had been started by the vSphere Cloud plugin. When I reconfigure the running build node (ex: when I might configure the idle action), I found that the build node will throw an NullPointer exception when it gets torn down:

java.lang.NullPointerException
	at hudson.slaves.DelegatingComputerLauncher.afterDisconnect(DelegatingComputerLauncher.java:69)
	at org.jenkinsci.plugins.vSphereCloudLauncher.afterDisconnect(vSphereCloudLauncher.java:311)
	at hudson.slaves.SlaveComputer$1.onClosed(SlaveComputer.java:625)
	at hudson.remoting.Channel.terminate(Channel.java:1102)
	at hudson.remoting.Channel$CloseCommand.execute(Channel.java:1314)
	at hudson.remoting.Channel$1.handle(Channel.java:606)
	at hudson.remoting.SynchronousCommandTransport$ReaderThread.run(SynchronousCommandTransport.java:85)

vSphereCloudLauncher inherits from DelegatingComputerLauncher. vSphereCloudLauncher's constructor expects to receive a ComputerLauncher instance for which it is delegating. The exception we are seeing occurs because getLauncher() is returning null for some reason. It should be returning the launcher instance that we are delegating to (in my case, this is the SSH Launcher):

    public void afterDisconnect(SlaveComputer computer, TaskListener listener) {
        getLauncher().afterDisconnect(computer, listener);
    }

It turns out that when you reconfigure a running build node, clicking the Save button will cause a new instance of vSphereCloudLauncher to be created, replacing the old launcher instance. Problem is that this new vSphereCloudLauncher's constructor is being passed null for the ComputerLauncher.

I reverted my changes and confirmed that this null launcher issue is happening on a build of master, so I didn't do anything other make it visible. I'm going to look into it a bit more and see if I can identify how the delegated launcher is being lost.

@justfalter
Copy link
Contributor Author

I modified the vSphereCloudLauncher constructor to throw an exception if launcher is null.
From the look of it, it appears that vSphereCloudProvisionedSlave is the class that's being configured when I go to computer/*/configure. I don't know exactly how the configuration stuff works within Jenkins, but my guess is that the ComputerLauncher either is not or can not be serialized to/from the configuration page. Kind of makes sense --- how would you serialize an active socket ?

Anyway, it seems like there are two possible solutions:

  1. Figure out if it is possible to pass the ComputerLauncher between the old and new instances of vSphereCloudProvisionedSlave.

or

  1. Register the ComputerLauncher instance somewhere, and pass some kind of id through vSphereCloudProvisionedSlave.

Exception:

java.lang.RuntimeException: launcher is null
	at org.jenkinsci.plugins.vSphereCloudLauncher.<init>(vSphereCloudLauncher.java:64)
	at org.jenkinsci.plugins.vSphereCloudSlave.<init>(vSphereCloudSlave.java:86)
	at org.jenkinsci.plugins.vSphereCloudProvisionedSlave.<init>(vSphereCloudProvisionedSlave.java:53)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
	at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
	at org.kohsuke.stapler.RequestImpl.invokeConstructor(RequestImpl.java:529)
	at org.kohsuke.stapler.RequestImpl.instantiate(RequestImpl.java:784)
	at org.kohsuke.stapler.RequestImpl.access$200(RequestImpl.java:83)
	at org.kohsuke.stapler.RequestImpl$TypePair.convertJSON(RequestImpl.java:678)
	at org.kohsuke.stapler.RequestImpl.bindJSON(RequestImpl.java:478)
	at org.kohsuke.stapler.RequestImpl.bindJSON(RequestImpl.java:474)
	at hudson.model.Descriptor.newInstance(Descriptor.java:599)
Caused: java.lang.Error: Failed to instantiate class org.jenkinsci.plugins.vSphereCloudProvisionedSlave from {"name":"MikeRyanTest_Centos7_reusable_1","vsDescription":"core-vc","vmName":"MikeRyanTest_Centos7_reusable_1","snapName":"","nodeDescription":"","numExecutors":"4","remoteFS":"/home/vagrant","labelString":"centos7_reusable","mode":"EXCLUSIVE","launchSupportForced":false,"waitForVMTools":false,"launchDelay":"10","":["","org.jenkinsci.plugins.vsphere.VSphereCloudRetentionStrategy"],"retentionStrategy":{"stapler-class":"org.jenkinsci.plugins.vsphere.VSphereCloudRetentionStrategy","idleMinutes":"1"},"LimitedTestRunCount":"5","idleOption":"Reset","nodeProperties":{"stapler-class-bag":"true"},"Jenkins-Crumb":"3f2c5ebfd5dea2d2e38b0494d178c29d04aba72315441779aabbf40ea3f03f74"}
	at hudson.model.Descriptor.newInstance(Descriptor.java:607)
	at hudson.model.Node.reconfigure(Node.java:549)
	at hudson.model.Computer.doConfigSubmit(Computer.java:1509)
	at java.lang.invoke.MethodHandle.invokeWithArguments(MethodHandle.java:627)
	at org.kohsuke.stapler.Function$MethodFunction.invoke(Function.java:396)
	at org.kohsuke.stapler.Function$InstanceFunction.invoke(Function.java:408)
	at org.kohsuke.stapler.Function.bindAndInvoke(Function.java:212)
	at org.kohsuke.stapler.SelectionInterceptedFunction$Adapter.invoke(SelectionInterceptedFunction.java:36)
	at org.kohsuke.stapler.verb.HttpVerbInterceptor.invoke(HttpVerbInterceptor.java:48)
	at org.kohsuke.stapler.SelectionInterceptedFunction.bindAndInvoke(SelectionInterceptedFunction.java:26)
	at org.kohsuke.stapler.Function.bindAndInvokeAndServeResponse(Function.java:145)
	at org.kohsuke.stapler.MetaClass$11.doDispatch(MetaClass.java:535)
	at org.kohsuke.stapler.NameBasedDispatcher.dispatch(NameBasedDispatcher.java:58)
	at org.kohsuke.stapler.Stapler.tryInvoke(Stapler.java:747)
Caused: javax.servlet.ServletException
	at org.kohsuke.stapler.Stapler.tryInvoke(Stapler.java:797)
	at org.kohsuke.stapler.Stapler.invoke(Stapler.java:878)
	at org.kohsuke.stapler.MetaClass$9.dispatch(MetaClass.java:456)
	at org.kohsuke.stapler.Stapler.tryInvoke(Stapler.java:747)
	at org.kohsuke.stapler.Stapler.invoke(Stapler.java:878)
	at org.kohsuke.stapler.MetaClass$2.doDispatch(MetaClass.java:219)
	at org.kohsuke.stapler.NameBasedDispatcher.dispatch(NameBasedDispatcher.java:58)
	at org.kohsuke.stapler.Stapler.tryInvoke(Stapler.java:747)
	at org.kohsuke.stapler.Stapler.invoke(Stapler.java:878)
	at org.kohsuke.stapler.Stapler.invoke(Stapler.java:676)
	at org.kohsuke.stapler.Stapler.service(Stapler.java:238)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:729)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:292)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:154)
	at jenkins.security.ResourceDomainFilter.doFilter(ResourceDomainFilter.java:76)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at hudson.plugins.greenballs.GreenBallFilter.doFilter(GreenBallFilter.java:59)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at jenkins.telemetry.impl.UserLanguages$AcceptLanguageFilter.doFilter(UserLanguages.java:128)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at hudson.util.PluginServletFilter.doFilter(PluginServletFilter.java:157)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at hudson.security.csrf.CrumbFilter.doFilter(CrumbFilter.java:153)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:84)
	at hudson.security.UnwrapSecurityExceptionFilter.doFilter(UnwrapSecurityExceptionFilter.java:51)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at jenkins.security.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:118)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at org.acegisecurity.providers.anonymous.AnonymousProcessingFilter.doFilter(AnonymousProcessingFilter.java:125)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at org.acegisecurity.ui.rememberme.RememberMeProcessingFilter.doFilter(RememberMeProcessingFilter.java:142)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at org.acegisecurity.ui.AbstractProcessingFilter.doFilter(AbstractProcessingFilter.java:271)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at jenkins.security.BasicHeaderProcessor.doFilter(BasicHeaderProcessor.java:93)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at org.acegisecurity.context.HttpSessionContextIntegrationFilter.doFilter(HttpSessionContextIntegrationFilter.java:249)
	at hudson.security.HttpSessionContextIntegrationFilter2.doFilter(HttpSessionContextIntegrationFilter2.java:67)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at hudson.security.ChainedServletFilter.doFilter(ChainedServletFilter.java:90)
	at hudson.security.HudsonFilter.doFilter(HudsonFilter.java:171)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at org.kohsuke.stapler.compression.CompressionFilter.doFilter(CompressionFilter.java:49)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at hudson.util.CharacterEncodingFilter.doFilter(CharacterEncodingFilter.java:82)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at org.kohsuke.stapler.DiagnosticThreadNameFilter.doFilter(DiagnosticThreadNameFilter.java:30)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at jenkins.security.SuspiciousRequestFilter.doFilter(SuspiciousRequestFilter.java:36)
	at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:213)
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:106)
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:606)
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:141)
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:79)
	at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:616)
	at org.apache.catalina.valves.RemoteIpValve.invoke(RemoteIpValve.java:676)
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:88)
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:522)
	at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1095)
	at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:672)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1520)
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.run(NioEndpoint.java:1476)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
	at java.lang.Thread.run(Thread.java:748)

@pjdarton
Copy link
Member

pjdarton commented Jun 4, 2020

AIUI, the "on idle" actions were designed before Jenkins had a cloud API so that folks could manually create VMs in vSphere and manually define vSphere-slave nodes in Jenkins with instructions to revert those VMs to a snapshot or similar in between builds.
So, if you're manually creating a slave, you should be able to chose a vSphere slave and set the on-idle action on that.

It's not much of a surprise if it's not possible to change the settings on a dynamically-provisioned slave.
I agree that it'd be nice to be able to do that, but those slaves are really intended to have their Jenkins configuration read-only once created - any editing of those should really be done via the template.
I would suggest that you try to avoid "going down a rabbit hole" chasing existing deficiencies in functionality - to get this PR merged, all you need to prove is that you've not broken anything. Additional deficiencies can be addressed in other PRs, if you have the inclination.

@justfalter
Copy link
Contributor Author

Alright, then I'm going to file a separate issue about the saving of the slave configuration. It is, indeed, a rabbit hole.

@justfalter
Copy link
Contributor Author

@justfalter
Copy link
Contributor Author

AIUI, the "on idle" actions were designed before Jenkins had a cloud API so that folks could manually create VMs in vSphere and manually define vSphere-slave nodes in Jenkins with instructions to revert those VMs to a snapshot or similar in between builds.
So, if you're manually creating a slave, you should be able to chose a vSphere slave and set the on-idle action on that.

In Jenkins 2.222.3, I don't seem to get an option to manually create a vSphere-based build slave. I only see the options to create a "Permanent Agent" or to copy from an existing agent:
image

If I create a permanent agent, there aren't any options for specifying details about a vSphere-based VM, although it does seem to have the vSphere retention policies available.
image

@justfalter
Copy link
Contributor Author

Note: I see the same behavior in Jenkins 2.60.3 when running locally using mvn hpi:run.

With no ability to manually create a vsphere-based agent, I've got no way to test any impact on idle actions. Is there really anything else that I can test?

@justfalter
Copy link
Contributor Author

@pjdarton Any thoughts?

With no ability to manually create a vsphere-based agent, I've got no way to test any impact on idle actions. Is there really anything else that I can test?

@pjdarton Any thoughts on this?

@pjdarton
Copy link
Member

pjdarton commented Jun 9, 2020

Um, I'm not sure why you're not seeing any ability to create a vSphere node; that should be possible; it certainly used to be possible (in fact, at one point, it was possible to manually create the kind of node we dynamically create rather than just the static kind).

Try older versions of Jenkins; it's possible that there's a breaking-compatibility change in very recent versions of Jenkins (which should be dealt with as a separate bug)

@justfalter
Copy link
Contributor Author

Already tried with Jenkins 2.60.3 (via mvn hpi:run), and I see the same behavior.

@pjdarton
Copy link
Member

pjdarton commented Jun 9, 2020

Oh. That's, um, disappointing. I wonder WTF broke that...

Well, I guess that gets you off the hook then; you can't break what's already impossible.

@pjdarton
Copy link
Member

pjdarton commented Jun 9, 2020

If you're happy with the code changes, I'm happy to push the merge button.
Let me know.

@justfalter
Copy link
Contributor Author

@pjdarton I'm good with the changes!

@justfalter
Copy link
Contributor Author

I'll file a ticket re: inability to manually create a vsphere build node

@pjdarton pjdarton merged commit 7ea9d7a into jenkinsci:master Jun 9, 2020
@justfalter justfalter deleted the fix-launcher-leak branch June 9, 2020 21:47
@justfalter
Copy link
Contributor Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants