Repository: locustio/locust

Issue:
Web page is confused when I shutdown the only slave
### Describe the bug
I started locust as master-slave mode (just 1 slave). When locust is running, I shutdown the slave.
When I refresh the web page, it become confused, refresh is useless.
![image](https://user-images.githubusercontent.com/36886351/76100770-385f5c80-6008-11ea-92c7-d8601cf8d2d5.png)
In console, master already set the slave missing
![console](https://user-images.githubusercontent.com/36886351/76101111-d94e1780-6008-11ea-9219-edfd14e1b6c0.png)

### Expected behavior
Web page is OK

### Actual behavior
See the image above

### Steps to reproduce
1. run locust master;
2. run 1 slave;
3. shutdown slave;
4. press "Stop" button
5. refresh the web page

### Environment

- OS: win 7 x64
- Python version: 3.7.0
- Locust version: 0.14.4
- Locust command line that you ran: locust -f boomer_master.py --master
- Locust file contents (anonymized if necessary):  Golang scripts (drived by boomer), I think it doesn't matter with this bug.


Discussion:
I can reproduce this bug using the steps described.

Resolving patch:
diff --git a/locust/runners.py b/locust/runners.py
--- a/locust/runners.py
+++ b/locust/runners.py
@@ -340,6 +340,10 @@ def hatching(self):
             @property
             def running(self):
                 return self.get_by_state(STATE_RUNNING)
+
+            @property
+            def missing(self):
+                return self.get_by_state(STATE_MISSING)
         
         self.clients = WorkerNodesDict()
         self.server = rpc.Server(master_bind_host, master_bind_port)
@@ -411,10 +415,11 @@ def start(self, locust_count, hatch_rate):
         self.state = STATE_HATCHING
 
     def stop(self):
-        self.state = STATE_STOPPING
-        for client in self.clients.all:
-            self.server.send_to_client(Message("stop", None, client.id))
-        self.environment.events.test_stop.fire(environment=self.environment)
+        if self.state not in [STATE_INIT, STATE_STOPPED, STATE_STOPPING]:
+            self.state = STATE_STOPPING
+            for client in self.clients.all:
+                self.server.send_to_client(Message("stop", None, client.id))
+            self.environment.events.test_stop.fire(environment=self.environment)
     
     def quit(self):
         if self.state not in [STATE_INIT, STATE_STOPPED, STATE_STOPPING]:
@@ -425,6 +430,11 @@ def quit(self):
             self.server.send_to_client(Message("quit", None, client.id))
         gevent.sleep(0.5) # wait for final stats report from all workers
         self.greenlet.kill(block=True)
+
+    def check_stopped(self):
+        if not self.state == STATE_INIT and all(map(lambda x: x.state != STATE_RUNNING and x.state != STATE_HATCHING, self.clients.all)):
+            self.state = STATE_STOPPED
+
     
     def heartbeat_worker(self):
         while True:
@@ -432,11 +442,16 @@ def heartbeat_worker(self):
             if self.connection_broken:
                 self.reset_connection()
                 continue
+
             for client in self.clients.all:
                 if client.heartbeat < 0 and client.state != STATE_MISSING:
                     logger.info('Worker %s failed to send heartbeat, setting state to missing.' % str(client.id))
                     client.state = STATE_MISSING
                     client.user_count = 0
+                    if self.worker_count - len(self.clients.missing) <= 0:
+                        logger.info("The last worker went missing, stopping test.")
+                        self.stop()
+                        self.check_stopped()
                 else:
                     client.heartbeat -= 1
 
@@ -496,11 +511,14 @@ def client_listener(self):
                 if msg.node_id in self.clients:
                     del self.clients[msg.node_id]
                     logger.info("Client %r quit. Currently %i clients connected." % (msg.node_id, len(self.clients.ready)))
+                    if self.worker_count - len(self.clients.missing) <= 0:
+                        logger.info("The last worker quit, stopping test.")
+                        self.stop()
             elif msg.type == "exception":
                 self.log_exception(msg.node_id, msg.data["msg"], msg.data["traceback"])
 
-            if not self.state == STATE_INIT and all(map(lambda x: x.state != STATE_RUNNING and x.state != STATE_HATCHING, self.clients.all)):
-                self.state = STATE_STOPPED
+            self.check_stopped()
+
 
     @property
     def worker_count(self):


Test patch:
diff --git a/locust/test/test_runners.py b/locust/test/test_runners.py
--- a/locust/test/test_runners.py
+++ b/locust/test/test_runners.py
@@ -12,7 +12,7 @@
 from locust.exception import LocustError, RPCError, StopLocust
 from locust.rpc import Message
 from locust.runners import LocustRunner, LocalLocustRunner, MasterLocustRunner, WorkerNode, \
-     WorkerLocustRunner, STATE_INIT, STATE_HATCHING, STATE_RUNNING, STATE_MISSING
+     WorkerLocustRunner, STATE_INIT, STATE_HATCHING, STATE_RUNNING, STATE_MISSING, STATE_STOPPED
 from locust.stats import RequestStats
 from locust.test.testcases import LocustTestCase
 
@@ -452,6 +452,48 @@ def test_master_marks_downed_workers_as_missing(self):
             # print(master.clients['fake_client'].__dict__)
             assert master.clients['fake_client'].state == STATE_MISSING
 
+    def test_last_worker_quitting_stops_test(self):
+        with mock.patch("locust.rpc.rpc.Server", mocked_rpc()) as server:
+            master = self.get_runner()
+            server.mocked_send(Message("client_ready", None, "fake_client1"))
+            server.mocked_send(Message("client_ready", None, "fake_client2"))
+
+            master.start(1, 2)
+            server.mocked_send(Message("hatching", None, "fake_client1"))
+            server.mocked_send(Message("hatching", None, "fake_client2"))
+
+            server.mocked_send(Message("quit", None, "fake_client1"))
+            sleep(0)
+            self.assertEqual(1, len(master.clients.all))
+            self.assertNotEqual(STATE_STOPPED, master.state, "Not all workers quit but test stopped anyway.")
+
+            server.mocked_send(Message("quit", None, "fake_client2"))
+            sleep(0)
+            self.assertEqual(0, len(master.clients.all))
+            self.assertEqual(STATE_STOPPED, master.state, "All workers quit but test didn't stop.")
+
+    @mock.patch("locust.runners.HEARTBEAT_INTERVAL", new=0.1)
+    def test_last_worker_missing_stops_test(self):
+        with mock.patch("locust.rpc.rpc.Server", mocked_rpc()) as server:
+            master = self.get_runner()
+            server.mocked_send(Message("client_ready", None, "fake_client1"))
+            server.mocked_send(Message("client_ready", None, "fake_client2"))
+
+            master.start(1, 2)
+            server.mocked_send(Message("hatching", None, "fake_client1"))
+            server.mocked_send(Message("hatching", None, "fake_client2"))
+
+            sleep(0.3)
+            server.mocked_send(Message("heartbeat", {'state': STATE_RUNNING, 'current_cpu_usage': 50}, "fake_client1"))
+
+            sleep(0.3)
+            self.assertEqual(1, len(master.clients.missing))
+            self.assertNotEqual(STATE_STOPPED, master.state, "Not all workers went missing but test stopped anyway.")
+
+            sleep(0.3)
+            self.assertEqual(2, len(master.clients.missing))
+            self.assertEqual(STATE_STOPPED, master.state, "All workers went missing but test didn't stop.")
+
     def test_master_total_stats(self):
         with mock.patch("locust.rpc.rpc.Server", mocked_rpc()) as server:
             master = self.get_runner()