Skip to content

Commit eda4fbb

Browse files
committed
PYTHON-2043 Spawn mongocryptd as a daemon process and silence resource warnings
1 parent e627321 commit eda4fbb

File tree

2 files changed

+146
-3
lines changed

2 files changed

+146
-3
lines changed

pymongo/daemon.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Copyright 2019-present MongoDB, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Support for spawning a daemon process.
16+
17+
PyMongo only attempts to spawn the mongocryptd daemon process when automatic
18+
client-side field level encryption is enabled. See
19+
:ref:`automatic-client-side-encryption` for more info.
20+
"""
21+
22+
import os
23+
import subprocess
24+
import sys
25+
import time
26+
27+
# The maximum amount of time to wait for the intermediate subprocess.
28+
_WAIT_TIMEOUT = 10
29+
_THIS_FILE = os.path.realpath(__file__)
30+
31+
if sys.version_info[0] < 3:
32+
def _popen_wait(popen, timeout):
33+
"""Implement wait timeout support for Python 2."""
34+
from pymongo.monotonic import time as _time
35+
deadline = _time() + timeout
36+
# Initial delay of 1ms
37+
delay = .0005
38+
while True:
39+
returncode = popen.poll()
40+
if returncode is not None:
41+
return returncode
42+
43+
remaining = deadline - _time()
44+
if remaining <= 0:
45+
# Just return None instead of raising an error.
46+
return None
47+
delay = min(delay * 2, remaining, .5)
48+
time.sleep(delay)
49+
50+
else:
51+
def _popen_wait(popen, timeout):
52+
"""Implement wait timeout support for Python 3."""
53+
try:
54+
return popen.wait(timeout=timeout)
55+
except subprocess.TimeoutExpired:
56+
# Silence TimeoutExpired errors.
57+
return None
58+
59+
60+
def _silence_resource_warning(popen):
61+
"""Silence Popen's ResourceWarning.
62+
63+
Note this should only be used if the process was created as a daemon.
64+
"""
65+
# Set the returncode to avoid this warning when popen is garbage collected:
66+
# "ResourceWarning: subprocess XXX is still running".
67+
# See https://bugs.python.org/issue38890 and
68+
# https://bugs.python.org/issue26741.
69+
popen.returncode = 0
70+
71+
72+
if sys.platform == 'win32':
73+
# On Windows we spawn the daemon process simply by using DETACHED_PROCESS.
74+
_DETACHED_PROCESS = getattr(subprocess, 'DETACHED_PROCESS', 0x00000008)
75+
76+
def _spawn_daemon(args):
77+
"""Spawn a daemon process (Windows)."""
78+
with open(os.devnull, 'r+b') as devnull:
79+
popen = subprocess.Popen(
80+
args,
81+
creationflags=_DETACHED_PROCESS,
82+
stdin=devnull, stderr=devnull, stdout=devnull)
83+
_silence_resource_warning(popen)
84+
else:
85+
# On Unix we spawn the daemon process with a double Popen.
86+
# 1) The first Popen runs this file as a Python script using the current
87+
# interpreter.
88+
# 2) The script then decouples itself and performs the second Popen to
89+
# spawn the daemon process.
90+
# 3) The original process waits up to 10 seconds for the script to exit.
91+
#
92+
# Note that we do not call fork() directly because we want this procedure
93+
# to be safe to call from any thread. Using Popen instead of fork also
94+
# avoids triggering the application's os.register_at_fork() callbacks when
95+
# we spawn the mongocryptd daemon process.
96+
def _spawn(args):
97+
"""Spawn the process and silence stdout/stderr."""
98+
with open(os.devnull, 'r+b') as devnull:
99+
return subprocess.Popen(
100+
args,
101+
close_fds=True,
102+
stdin=devnull, stderr=devnull, stdout=devnull)
103+
104+
105+
def _spawn_daemon_double_popen(args):
106+
"""Spawn a daemon process using a double subprocess.Popen."""
107+
spawner_args = [sys.executable, _THIS_FILE]
108+
spawner_args.extend(args)
109+
temp_proc = subprocess.Popen(spawner_args, close_fds=True)
110+
# Reap the intermediate child process to avoid creating zombie
111+
# processes.
112+
_popen_wait(temp_proc, _WAIT_TIMEOUT)
113+
114+
115+
def _spawn_daemon(args):
116+
"""Spawn a daemon process (Unix)."""
117+
# "If Python is unable to retrieve the real path to its executable,
118+
# sys.executable will be an empty string or None".
119+
if sys.executable:
120+
_spawn_daemon_double_popen(args)
121+
else:
122+
# Fallback to spawn a non-daemon process without silencing the
123+
# resource warning. We do not use fork here because it is not
124+
# safe to call from a thread on all systems.
125+
# Unfortunately, this means that:
126+
# 1) If the parent application is killed via Ctrl-C, the
127+
# non-daemon process will also be killed.
128+
# 2) Each non-daemon process will hang around as a zombie process
129+
# until the main application exits.
130+
_spawn(args)
131+
132+
133+
if __name__ == '__main__':
134+
# Attempt to start a new session to decouple from the parent.
135+
if hasattr(os, 'setsid'):
136+
try:
137+
os.setsid()
138+
except OSError:
139+
pass
140+
141+
# We are performing a double fork (Popen) to spawn the process as a
142+
# daemon so it is safe to ignore the resource warning.
143+
_silence_resource_warning(_spawn(sys.argv[1:]))
144+
os._exit(0)

pymongo/encryption.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from pymongo.ssl_support import get_ssl_context
5353
from pymongo.uri_parser import parse_host
5454
from pymongo.write_concern import WriteConcern
55+
from pymongo.daemon import _spawn_daemon
5556

5657

5758
_HTTPS_PORT = 443
@@ -146,9 +147,7 @@ def spawn(self):
146147
self._spawned = True
147148
args = [self.opts._mongocryptd_spawn_path or 'mongocryptd']
148149
args.extend(self.opts._mongocryptd_spawn_args)
149-
# Silence mongocryptd output, users should pass --logpath.
150-
with open(os.devnull, 'wb') as devnull:
151-
subprocess.Popen(args, stdout=devnull, stderr=devnull)
150+
_spawn_daemon(args)
152151

153152
def mark_command(self, database, cmd):
154153
"""Mark a command for encryption.

0 commit comments

Comments
 (0)