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

RSDK-9591 - Kill all lingering module process before exiting #4657

Merged
merged 11 commits into from
Jan 10, 2025

Conversation

cheukt
Copy link
Member

@cheukt cheukt commented Dec 27, 2024

This is part two of two PRs that will hopefully help with shutting down all module processes before viam-server exits. Part one is here

Before doing this, I looked into assigning module processes to the same process group as the viam server and just kill the process group. However, we already have each module and process assign to unique process groups, and we use that property to kill each modules and processes separately if necessary. Changing that behavior would be risky, so did not pursue that path further.

We could kill each process in mod manager directly using the exposed unixpid, but figured we could just do it within each managed process, that way we get support in windows as well. It does mean I added Kill() in a few interfaces, but it will hopefully be extensible in case anything else may need killing.

The idea behind this is for a Kill() call to propagate from the viam-server at the end of 90s, and we should not block on anything if possible. The Kill() does not care about the resource graph, only that we kill processes/module processes spawned by the server. I did not do the killing in parallel, since the calls will not block. I can see things racing with Close(), but I think the mitigation would be to make sure that kill/close is idempotent and will not panic if overlapping. This Kill() call does happen in the same goroutine that eventually calls log.Fatal, is that good enough for now or should we create a different goroutine so that we can guarantee that the viam-server exits by the 90s mark?

Ideas for testing? I've tested on a python module and observed that the module process does get killed, and would be good to test on setups where this is happening.

@viambot viambot added the safe to test This pull request is marked safe to test from a trusted zone label Dec 27, 2024
@@ -280,6 +283,9 @@ func (s *robotServer) serveWeb(ctx context.Context, cfg *config.Config) (err err
case <-doneServing:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're here, I'd recommend removing this whole case <-doneServing stuff (and incidentally the select statement) and move straight to the killing/logging.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is that? I thought the justification above for the code as it is makes sense

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ug -- it does. But it's a self-inflicted mess. I'll make a change after this goes in.

@@ -280,6 +283,9 @@ func (s *robotServer) serveWeb(ctx context.Context, cfg *config.Config) (err err
case <-doneServing:
return true
default:
if myRobot != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If myRobot can be nil here -- this is a data race.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would that be an issue? I can see that myRobot could have started some processes but not yet returned, but I don't know if we can protect against that completely.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same remark as the other cases

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added some locking around this

// Kill will attempt to kill any processes on the system started by the robot as quickly as possible.
// This operation is not clean and will not wait for completion.
func (r *localRobot) Kill() {
if r.manager != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we justify this isn't a data race?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

r.manager could be nil if startup fails/hangs, but yes, it could also be a data race.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We talked offline -- I agree that the a mutex doesn't fix the "logical" race where we may observe the manager is nil a moment before it gets assigned.

But TSAN/Go's data race detection will notice this. Strictly speaking if one has two threads reading and writing a variable/memory address at the same time:

X initialized to 0
| Writer       | Reader |
|--------------+--------|
| Write(X = 1) | Read X |

While our program may be OK with the reader seeing 0 or the reader seeing 1, it's not necessarily the case that for all architectures the Reader can only see 0 or 1.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at this again, this isn't a data race - there's no chance for Kill() to be called before r.manager is assigned (Kill() can only be called if robot exists, and robot only exists if r.manager is assigned)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great -- much prefer to be able to assume members are non-nil.

@@ -280,6 +283,9 @@ func (s *robotServer) serveWeb(ctx context.Context, cfg *config.Config) (err err
case <-doneServing:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ug -- it does. But it's a self-inflicted mess. I'll make a change after this goes in.

// Kill will attempt to kill any processes on the system started by the robot as quickly as possible.
// This operation is not clean and will not wait for completion.
func (r *localRobot) Kill() {
if r.manager != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We talked offline -- I agree that the a mutex doesn't fix the "logical" race where we may observe the manager is nil a moment before it gets assigned.

But TSAN/Go's data race detection will notice this. Strictly speaking if one has two threads reading and writing a variable/memory address at the same time:

X initialized to 0
| Writer       | Reader |
|--------------+--------|
| Write(X = 1) | Read X |

While our program may be OK with the reader seeing 0 or the reader seeing 1, it's not necessarily the case that for all architectures the Reader can only see 0 or 1.

// TODO: Kill processes in processManager as well.

// moduleManager may be nil in tests
if manager.moduleManager != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this can be a data race as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep! added some locking around this

@@ -280,6 +283,9 @@ func (s *robotServer) serveWeb(ctx context.Context, cfg *config.Config) (err err
case <-doneServing:
return true
default:
if myRobot != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same remark as the other cases

@@ -261,6 +262,8 @@ func (s *robotServer) serveWeb(ctx context.Context, cfg *config.Config) (err err
forceShutdown := make(chan struct{})
defer func() { <-forceShutdown }()

var myRobot robot.LocalRobot

utils.PanicCapturingGo(func() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be awesome if this force shutdown goroutine was its own method/function as we've been doing for our various unnamed async lambdas.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, but would like to defer that work to https://viam.atlassian.net/browse/RSDK-9708. There's a bit of refactoring that has to be done (it'd look pretty ugly unless we add some vars to the server object), and I'd rather do it separately

@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Jan 9, 2025
// Kill will attempt to kill any processes on the system started by the robot as quickly as possible.
// This operation is not clean and will not wait for completion.
func (r *localRobot) Kill() {
r.manager.Kill()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a little awkward that localRobot.Kill only calls kill on the resource manager.

And the resource manager only calls kill on the mod manager.

And localRobot already has a handle on the modmanager. So why doesn't it call kill directly? Or just have the part that's about to do the log.Fatal/os.Exit call kill on the modmanager?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping the abstractions I think is better in the long run - the resource manager will eventually call kill on the process manager.
the part that's about to do log.Fatal doesn't have a handle to the modmanager, since it only has access to the LocalRobot interface.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keeping the abstractions I think is better in the long run

If I'm adding some plumbing from local robot -> modmanager, when should I use localRobot.modmanager directly and when should I add additional plumbing through localRobot.manager?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I'm fine with leaving this as-is. The above is me just coming to the realization that there's some abstraction that I didn't know existed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localRobot.modmanager

I'm not aware of an existing localRobot.modmanager field? Or are you suggesting adding one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @dgottlieb is asking whether he should use localRobot.manager.modmanager.

To @dgottlieb's question, I don't have a good answer. I feel like localRobot and resource manager are already a bit mangled, and I think adding the plumbing will end up making the unmangling slightly more manageable if we ever decide to do it

@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Jan 9, 2025
@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Jan 10, 2025
@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Jan 10, 2025
@cheukt cheukt marked this pull request as ready for review January 10, 2025 15:41
@cheukt
Copy link
Member Author

cheukt commented Jan 10, 2025

this is ready for full review! added 2 small-ish tests that test the expected behavior. hopefully they pass on CI or I might just skip them (ran into a lot of issues with the manage goroutine not returning from its Wait() in the goutils tests)

if robot != nil {
robot.Kill()
}
theRobotLock.Unlock()
Copy link
Member

@dgottlieb dgottlieb Jan 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is accidental? Unless Kill is funny, this looks like a double unlock.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for catching!

@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Jan 10, 2025
mgr.Kill()

testutils.WaitForAssertion(t, func(tb testing.TB) {
test.That(tb, logs.FilterMessageSnippet("Killing module").Len(),
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not the most robust test, but using the Status() method didn't work

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the Status() method didn't work.

Any idea why? Still reported being alive?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err is returning nil, so I assume so. Maybe a quirk of how it works on CI, because the test with Status() worked fine locally

Copy link
Member

@benjirewis benjirewis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! Just some final nits...

@@ -211,6 +211,21 @@ func (mgr *Manager) Close(ctx context.Context) error {
return err
}

// Kill kills module processes. This is best effort as we do not
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kill kills modules processes.

Per my nits on the other PR, we're technically killing module process groups here. Could we make sure that's at least clear in documentation here and wherever else we're calling managedProcess.Kill and describing what we're doing? The group vs. process thing led to some initial (and perhaps continued) confusion for me, so, while this looks pretty good code-wise, I'd love to not be confused looking back at this stuff.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated comments

mgr.Kill()

testutils.WaitForAssertion(t, func(tb testing.TB) {
test.That(tb, logs.FilterMessageSnippet("Killing module").Len(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the Status() method didn't work.

Any idea why? Still reported being alive?

// Kill will attempt to kill any processes on the system started by the robot as quickly as possible.
// This operation is not clean and will not wait for completion.
func (r *localRobot) Kill() {
r.manager.Kill()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localRobot.modmanager

I'm not aware of an existing localRobot.modmanager field? Or are you suggesting adding one?

func TestKill(t *testing.T) {
// this test will not pass in CI as the managed process's manage goroutine
// will not return from Wait() and thus fail the goroutine leak detection.
t.Skip()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate that this test exists, but is it even meaningful to have it around? A t.Skip with no TODO to unskip feels wrong to me. Or were you meaning to remove this t.Skip?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's meaningful to be able to run locally, but added a ticket as well https://viam.atlassian.net/browse/RSDK-9722

@@ -50,6 +51,8 @@ type resourceManager struct {
resources *resource.Graph
processManager pexec.ProcessManager
processConfigs map[string]pexec.ProcessConfig
// modManagerLock controls access to the moduleManager
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Can we just elaborate a bit on comment explaining that a Kill from local robot may try to access the module manager concurrently with resource manager accesses?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Jan 10, 2025
@cheukt cheukt requested a review from benjirewis January 10, 2025 20:43
Copy link
Member

@benjirewis benjirewis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@cheukt cheukt merged commit 819123b into viamrobotics:main Jan 10, 2025
16 checks passed
@cheukt cheukt deleted the close-faster branch January 10, 2025 21:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
safe to test This pull request is marked safe to test from a trusted zone
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants