1"""Utilities for connecting to jupyter kernels
2
3The :class:`ConnectionFileMixin` class in this module encapsulates the logic
4related to writing and reading connections files.
5"""
6
7# Copyright (c) Jupyter Development Team.
8# Distributed under the terms of the Modified BSD License.
9from __future__ import annotations
10
11import errno
12import glob
13import json
14import os
15import socket
16import stat
17import tempfile
18import warnings
19from getpass import getpass
20from typing import TYPE_CHECKING, Any, cast
21
22import zmq
23from jupyter_core.paths import jupyter_data_dir, jupyter_runtime_dir, secure_write
24from traitlets import Bool, Bytes, CaselessStrEnum, Instance, Integer, Type, Unicode, observe
25from traitlets.config import LoggingConfigurable, SingletonConfigurable
26from typing_extensions import TypedDict
27
28from .localinterfaces import localhost
29from .utils import _filefind
30
31if TYPE_CHECKING:
32 from jupyter_client import BlockingKernelClient
33
34 from .session import Session
35
36# Define custom type for kernel connection info
37
38
39class KernelConnectionInfo(TypedDict, extra_items=str | bytes | int, total=False): # type: ignore[call-arg]
40 shell_port: int
41 iopub_port: int
42 stdin_port: int
43 control_port: int
44 hb_port: int
45 ip: str
46 key: str
47 transport: str
48 signature_scheme: str
49 kernel_name: str
50 session: Session
51 curve_publickey: str
52 curve_secretkey: str
53
54
55def write_connection_file(
56 fname: str | None = None,
57 shell_port: int = 0,
58 iopub_port: int = 0,
59 stdin_port: int = 0,
60 hb_port: int = 0,
61 control_port: int = 0,
62 ip: str = "",
63 key: bytes = b"",
64 transport: str = "tcp",
65 signature_scheme: str = "hmac-sha256",
66 kernel_name: str = "",
67 curve_publickey: bytes | None = None,
68 curve_secretkey: bytes | None = None,
69 **kwargs: Any,
70) -> tuple[str, KernelConnectionInfo]:
71 """Generates a JSON config file, including the selection of random ports.
72
73 Parameters
74 ----------
75
76 fname : unicode
77 The path to the file to write
78
79 shell_port : int, optional
80 The port to use for ROUTER (shell) channel.
81
82 iopub_port : int, optional
83 The port to use for the SUB channel.
84
85 stdin_port : int, optional
86 The port to use for the ROUTER (raw input) channel.
87
88 control_port : int, optional
89 The port to use for the ROUTER (control) channel.
90
91 hb_port : int, optional
92 The port to use for the heartbeat REP channel.
93
94 ip : str, optional
95 The ip address the kernel will bind to.
96
97 key : bytes, optional
98 The Session key used for message authentication.
99
100 signature_scheme : str, optional
101 The scheme used for message authentication.
102 This has the form 'digest-hash', where 'digest'
103 is the scheme used for digests, and 'hash' is the name of the hash function
104 used by the digest scheme.
105 Currently, 'hmac' is the only supported digest scheme,
106 and 'sha256' is the default hash function.
107
108 kernel_name : str, optional
109 The name of the kernel currently connected to.
110
111 curve_publickey : bytes, optional
112 CurveZMQ public key (Z85).
113
114 curve_secretkey : bytes, optional
115 CurveZMQ secret key (Z85).
116 """
117 if not ip:
118 ip = localhost()
119 # default to temporary connector file
120 if not fname:
121 fd, fname = tempfile.mkstemp(".json")
122 os.close(fd)
123
124 # Find open ports as necessary.
125
126 ports: list[int] = []
127 sockets: list[socket.socket] = []
128 ports_needed = (
129 int(shell_port <= 0)
130 + int(iopub_port <= 0)
131 + int(stdin_port <= 0)
132 + int(control_port <= 0)
133 + int(hb_port <= 0)
134 )
135 if transport == "tcp":
136 for _ in range(ports_needed):
137 sock = socket.socket()
138 # struct.pack('ii', (0,0)) is 8 null bytes
139 sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, b"\0" * 8)
140 sock.bind((ip, 0))
141 sockets.append(sock)
142 for sock in sockets:
143 port = sock.getsockname()[1]
144 sock.close()
145 ports.append(port)
146 else:
147 N = 1
148 for _ in range(ports_needed):
149 while os.path.exists(f"{ip}-{N!s}"):
150 N += 1
151 ports.append(N)
152 N += 1
153 if shell_port <= 0:
154 shell_port = ports.pop(0)
155 if iopub_port <= 0:
156 iopub_port = ports.pop(0)
157 if stdin_port <= 0:
158 stdin_port = ports.pop(0)
159 if control_port <= 0:
160 control_port = ports.pop(0)
161 if hb_port <= 0:
162 hb_port = ports.pop(0)
163
164 cfg: KernelConnectionInfo = {
165 "shell_port": shell_port,
166 "iopub_port": iopub_port,
167 "stdin_port": stdin_port,
168 "control_port": control_port,
169 "hb_port": hb_port,
170 }
171 cfg["ip"] = ip
172 cfg["key"] = key.decode()
173 cfg["transport"] = transport
174 cfg["signature_scheme"] = signature_scheme
175 cfg["kernel_name"] = kernel_name
176 if curve_publickey is not None:
177 cfg["curve_publickey"] = curve_publickey.decode("ascii")
178 if curve_secretkey is not None:
179 cfg["curve_secretkey"] = curve_secretkey.decode("ascii")
180 cfg.update(kwargs) # type: ignore[typeddict-item]
181
182 # Only ever write this file as user read/writeable
183 # This would otherwise introduce a vulnerability as a file has secrets
184 # which would let others execute arbitrary code as you
185 with secure_write(fname) as f:
186 f.write(json.dumps(cfg, indent=2))
187
188 if hasattr(stat, "S_ISVTX"):
189 # set the sticky bit on the parent directory of the file
190 # to ensure only owner can remove it
191 runtime_dir = os.path.dirname(fname)
192 if runtime_dir:
193 permissions = os.stat(runtime_dir).st_mode
194 new_permissions = permissions | stat.S_ISVTX
195 if new_permissions != permissions:
196 try:
197 os.chmod(runtime_dir, new_permissions)
198 except OSError as e:
199 if e.errno == errno.EPERM:
200 # suppress permission errors setting sticky bit on runtime_dir,
201 # which we may not own.
202 pass
203 return fname, cfg
204
205
206def find_connection_file(
207 filename: str = "kernel-*.json",
208 path: str | list[str] | None = None,
209 profile: str | None = None,
210) -> str:
211 """find a connection file, and return its absolute path.
212
213 The current working directory and optional search path
214 will be searched for the file if it is not given by absolute path.
215
216 If the argument does not match an existing file, it will be interpreted as a
217 fileglob, and the matching file in the profile's security dir with
218 the latest access time will be used.
219
220 Parameters
221 ----------
222 filename : str
223 The connection file or fileglob to search for.
224 path : str or list of strs[optional]
225 Paths in which to search for connection files.
226
227 Returns
228 -------
229 str : The absolute path of the connection file.
230 """
231 if profile is not None:
232 warnings.warn(
233 "Jupyter has no profiles. profile=%s has been ignored." % profile, stacklevel=2
234 )
235 if path is None:
236 path = [".", jupyter_runtime_dir()]
237 if isinstance(path, str):
238 path = [path]
239
240 try:
241 # first, try explicit name
242 return _filefind(filename, path)
243 except OSError:
244 pass
245
246 # not found by full name
247
248 if "*" in filename:
249 # given as a glob already
250 pat = filename
251 else:
252 # accept any substring match
253 pat = "*%s*" % filename
254
255 matches = []
256 for p in path:
257 matches.extend(glob.glob(os.path.join(p, pat)))
258
259 matches = [os.path.abspath(m) for m in matches]
260 if not matches:
261 msg = f"Could not find {filename!r} in {path!r}"
262 raise OSError(msg)
263 elif len(matches) == 1:
264 return matches[0]
265 else:
266 # get most recent match, by access time:
267 return sorted(matches, key=lambda f: os.stat(f).st_atime)[-1]
268
269
270def tunnel_to_kernel(
271 connection_info: str | KernelConnectionInfo,
272 sshserver: str,
273 sshkey: str | None = None,
274) -> tuple[Any, ...]:
275 """tunnel connections to a kernel via ssh
276
277 This will open five SSH tunnels from localhost on this machine to the
278 ports associated with the kernel. They can be either direct
279 localhost-localhost tunnels, or if an intermediate server is necessary,
280 the kernel must be listening on a public IP.
281
282 Parameters
283 ----------
284 connection_info : dict or str (path)
285 Either a connection dict, or the path to a JSON connection file
286 sshserver : str
287 The ssh sever to use to tunnel to the kernel. Can be a full
288 `user@server:port` string. ssh config aliases are respected.
289 sshkey : str [optional]
290 Path to file containing ssh key to use for authentication.
291 Only necessary if your ssh config does not already associate
292 a keyfile with the host.
293
294 Returns
295 -------
296
297 (shell, iopub, stdin, hb, control) : ints
298 The five ports on localhost that have been forwarded to the kernel.
299 """
300 from .ssh import tunnel
301
302 if isinstance(connection_info, str):
303 # it's a path, unpack it
304 with open(connection_info) as f:
305 connection_info = json.loads(f.read())
306
307 cf = cast(dict[str, Any], connection_info)
308
309 lports = tunnel.select_random_ports(5)
310 rports = (
311 cf["shell_port"],
312 cf["iopub_port"],
313 cf["stdin_port"],
314 cf["hb_port"],
315 cf["control_port"],
316 )
317
318 remote_ip = cf["ip"]
319
320 if tunnel.try_passwordless_ssh(sshserver, sshkey):
321 password: bool | str = False
322 else:
323 password = getpass("SSH Password for %s: " % sshserver)
324
325 for lp, rp in zip(lports, rports, strict=False):
326 tunnel.ssh_tunnel(lp, rp, sshserver, remote_ip, sshkey, password)
327
328 return tuple(lports)
329
330
331# -----------------------------------------------------------------------------
332# Mixin for classes that work with connection files
333# -----------------------------------------------------------------------------
334
335channel_socket_types = {
336 "hb": zmq.REQ,
337 "shell": zmq.DEALER,
338 "iopub": zmq.SUB,
339 "stdin": zmq.DEALER,
340 "control": zmq.DEALER,
341}
342
343port_names = ["%s_port" % channel for channel in ("shell", "stdin", "iopub", "hb", "control")]
344
345
346class ConnectionFileMixin(LoggingConfigurable):
347 """Mixin for configurable classes that work with connection files"""
348
349 data_dir: str | Unicode = Unicode()
350
351 def _data_dir_default(self) -> str:
352 return jupyter_data_dir()
353
354 # The addresses for the communication channels
355 connection_file = Unicode(
356 "",
357 config=True,
358 help="""JSON file in which to store connection info [default: kernel-<pid>.json]
359
360 This file will contain the IP, ports, and authentication key needed to connect
361 clients to this kernel. By default, this file will be created in the security dir
362 of the current profile, but can be specified by absolute path.
363 """,
364 )
365 _connection_file_written = Bool(False)
366
367 transport = CaselessStrEnum(["tcp", "ipc"], default_value="tcp", config=True)
368 kernel_name: str | Unicode = Unicode()
369
370 context = Instance(zmq.Context)
371
372 ip = Unicode(
373 config=True,
374 help="""Set the kernel\'s IP address [default localhost].
375 If the IP address is something other than localhost, then
376 Consoles on other machines will be able to connect
377 to the Kernel, so be careful!""",
378 )
379
380 def _ip_default(self) -> str:
381 if self.transport == "ipc":
382 if self.connection_file:
383 return os.path.splitext(self.connection_file)[0] + "-ipc"
384 else:
385 return "kernel-ipc"
386 else:
387 return localhost()
388
389 @observe("ip")
390 def _ip_changed(self, change: Any) -> None:
391 if change["new"] == "*":
392 self.ip = "0.0.0.0" # noqa
393
394 # protected traits
395
396 hb_port = Integer(0, config=True, help="set the heartbeat port [default: random]")
397 shell_port = Integer(0, config=True, help="set the shell (ROUTER) port [default: random]")
398 iopub_port = Integer(0, config=True, help="set the iopub (PUB) port [default: random]")
399 stdin_port = Integer(0, config=True, help="set the stdin (ROUTER) port [default: random]")
400 control_port = Integer(0, config=True, help="set the control (ROUTER) port [default: random]")
401
402 # Optional CurveZMQ keys loaded from the connection file (Z85-encoded bytes).
403 # None when the kernel was not started with CurveZMQ enabled.
404 curve_publickey: Bytes | None = Bytes(allow_none=True, default_value=None)
405 curve_secretkey: Bytes | None = Bytes(allow_none=True, default_value=None)
406
407 # names of the ports with random assignment
408 _random_port_names: list[str] | None = None
409
410 @property
411 def ports(self) -> list[int]:
412 return [getattr(self, name) for name in port_names]
413
414 # The Session to use for communication with the kernel.
415 session = Instance("jupyter_client.session.Session")
416
417 def _session_default(self) -> Session:
418 from .session import Session
419
420 return Session(parent=self)
421
422 # --------------------------------------------------------------------------
423 # Connection and ipc file management
424 # --------------------------------------------------------------------------
425
426 def get_connection_info(self, session: bool = False) -> KernelConnectionInfo:
427 """Return the connection info as a dict
428
429 Parameters
430 ----------
431 session : bool [default: False]
432 If True, return our session object will be included in the connection info.
433 If False (default), the configuration parameters of our session object will be included,
434 rather than the session object itself.
435
436 Returns
437 -------
438 connect_info : dict
439 dictionary of connection information.
440 """
441 info: KernelConnectionInfo = {
442 "transport": self.transport,
443 "ip": self.ip,
444 "shell_port": self.shell_port,
445 "iopub_port": self.iopub_port,
446 "stdin_port": self.stdin_port,
447 "hb_port": self.hb_port,
448 "control_port": self.control_port,
449 }
450 if session:
451 # add *clone* of my session,
452 # so that state such as digest_history is not shared.
453 info["session"] = self.session.clone()
454 else:
455 # add session info
456 info.update(
457 {
458 "signature_scheme": self.session.signature_scheme,
459 "key": self.session.key,
460 }
461 )
462 if self.curve_publickey is not None and self.curve_secretkey is not None:
463 info["curve_publickey"] = self.curve_publickey.decode()
464 info["curve_secretkey"] = self.curve_secretkey.decode()
465 return info
466
467 # factory for blocking clients
468 blocking_class = Type(klass=object, default_value="jupyter_client.BlockingKernelClient")
469
470 def blocking_client(self) -> BlockingKernelClient:
471 """Make a blocking client connected to my kernel"""
472 info = self.get_connection_info()
473 bc = self.blocking_class(parent=self) # type:ignore[operator]
474 bc.load_connection_info(info)
475 return bc
476
477 def cleanup_connection_file(self) -> None:
478 """Cleanup connection file *if we wrote it*
479
480 Will not raise if the connection file was already removed somehow.
481 """
482 if self._connection_file_written:
483 # cleanup connection files on full shutdown of kernel we started
484 self._connection_file_written = False
485 try:
486 os.remove(self.connection_file)
487 except (OSError, AttributeError):
488 pass
489
490 def cleanup_ipc_files(self) -> None:
491 """Cleanup ipc files if we wrote them."""
492 if self.transport != "ipc":
493 return
494 for port in self.ports:
495 ipcfile = "%s-%i" % (self.ip, port)
496 try:
497 os.remove(ipcfile)
498 except OSError:
499 pass
500
501 def _record_random_port_names(self) -> None:
502 """Records which of the ports are randomly assigned.
503
504 Records on first invocation, if the transport is tcp.
505 Does nothing on later invocations."""
506
507 if self.transport != "tcp":
508 return
509 if self._random_port_names is not None:
510 return
511
512 self._random_port_names = []
513 for name in port_names:
514 if getattr(self, name) <= 0:
515 self._random_port_names.append(name)
516
517 def cleanup_random_ports(self) -> None:
518 """Forgets randomly assigned port numbers and cleans up the connection file.
519
520 Does nothing if no port numbers have been randomly assigned.
521 In particular, does nothing unless the transport is tcp.
522 """
523
524 if not self._random_port_names:
525 return
526
527 for name in self._random_port_names:
528 setattr(self, name, 0)
529
530 self.cleanup_connection_file()
531
532 def write_connection_file(self, **kwargs: Any) -> None:
533 """Write connection info to JSON dict in self.connection_file."""
534 if self._connection_file_written and os.path.exists(self.connection_file):
535 return
536
537 self.connection_file, cfg = write_connection_file(
538 self.connection_file,
539 transport=self.transport,
540 ip=self.ip,
541 key=self.session.key,
542 stdin_port=self.stdin_port,
543 iopub_port=self.iopub_port,
544 shell_port=self.shell_port,
545 hb_port=self.hb_port,
546 control_port=self.control_port,
547 signature_scheme=self.session.signature_scheme,
548 kernel_name=self.kernel_name,
549 curve_publickey=self.curve_publickey,
550 curve_secretkey=self.curve_secretkey,
551 **kwargs,
552 )
553 # write_connection_file also sets default ports:
554 self._record_random_port_names()
555 for name in port_names:
556 setattr(self, name, cast(int, cfg.get(name)))
557
558 self._connection_file_written = True
559
560 def load_connection_file(self, connection_file: str | None = None) -> None:
561 """Load connection info from JSON dict in self.connection_file.
562
563 Parameters
564 ----------
565 connection_file: unicode, optional
566 Path to connection file to load.
567 If unspecified, use self.connection_file
568 """
569 if connection_file is None:
570 connection_file = self.connection_file
571 self.log.debug("Loading connection file %s", connection_file)
572 with open(connection_file) as f:
573 info = json.load(f)
574 self.load_connection_info(info)
575
576 def load_connection_info(self, info: KernelConnectionInfo) -> None:
577 """Load connection info from a dict containing connection info.
578
579 Typically this data comes from a connection file
580 and is called by load_connection_file.
581
582 Parameters
583 ----------
584 info: dict
585 Dictionary containing connection_info.
586 See the connection_file spec for details.
587 """
588 self.transport = info.get("transport", self.transport)
589 self.ip = info.get("ip", self._ip_default())
590
591 self._record_random_port_names()
592 for name in port_names:
593 if getattr(self, name) == 0 and name in info:
594 # not overridden by config or cl_args
595 setattr(self, name, cast(int, info.get(name)))
596
597 if "key" in info:
598 key = info["key"]
599 key_bytes = key if isinstance(key, bytes) else key.encode() # type: ignore[redundant-expr,unreachable]
600 self.session.key = key_bytes
601 if "signature_scheme" in info:
602 self.session.signature_scheme = info["signature_scheme"]
603 if "curve_publickey" in info and "curve_secretkey" in info:
604 pub = info["curve_publickey"]
605 sec = info["curve_secretkey"]
606 self.curve_publickey = pub.encode() if isinstance(pub, str) else pub # type: ignore[redundant-expr]
607 self.curve_secretkey = sec.encode() if isinstance(sec, str) else sec # type: ignore[redundant-expr]
608
609 def _reconcile_connection_info(self, info: KernelConnectionInfo) -> None:
610 """Reconciles the connection information returned from the Provisioner.
611
612 Because some provisioners (like derivations of LocalProvisioner) may have already
613 written the connection file, this method needs to ensure that, if the connection
614 file exists, its contents match that of what was returned by the provisioner. If
615 the file does exist and its contents do not match, the file will be replaced with
616 the provisioner information (which is considered the truth).
617
618 If the file does not exist, the connection information in 'info' is loaded into the
619 KernelManager and written to the file.
620 """
621 # Prevent over-writing a file that has already been written with the same
622 # info. This is to prevent a race condition where the process has
623 # already been launched but has not yet read the connection file - as is
624 # the case with LocalProvisioners.
625 file_exists: bool = False
626 if os.path.exists(self.connection_file):
627 with open(self.connection_file) as f:
628 file_info = json.load(f)
629 # Prior to the following comparison, we need to adjust the value of "key" to
630 # be bytes, otherwise the comparison below will fail.
631 file_info["key"] = file_info["key"].encode()
632 if not self._equal_connections(info, file_info):
633 os.remove(self.connection_file) # Contents mismatch - remove the file
634 self._connection_file_written = False
635 else:
636 file_exists = True
637
638 if not file_exists:
639 # Load the connection info and write out file, clearing existing
640 # port-based attributes so they will be reloaded
641 for name in port_names:
642 setattr(self, name, 0)
643 self.load_connection_info(info)
644 self.write_connection_file()
645
646 # Ensure what is in KernelManager is what we expect.
647 km_info = self.get_connection_info()
648 if not self._equal_connections(info, km_info):
649 msg = (
650 "KernelManager's connection information already exists and does not match "
651 "the expected values returned from provisioner!"
652 )
653 raise ValueError(msg)
654
655 @staticmethod
656 def _equal_connections(conn1: KernelConnectionInfo, conn2: KernelConnectionInfo) -> bool:
657 """Compares pertinent keys of connection info data. Returns True if equivalent, False otherwise."""
658
659 pertinent_keys = [
660 "key",
661 "curve_publickey",
662 "curve_secretkey",
663 "ip",
664 "stdin_port",
665 "iopub_port",
666 "shell_port",
667 "control_port",
668 "hb_port",
669 "transport",
670 "signature_scheme",
671 ]
672
673 return all(conn1.get(key) == conn2.get(key) for key in pertinent_keys)
674
675 # --------------------------------------------------------------------------
676 # Creating connected sockets
677 # --------------------------------------------------------------------------
678
679 def _make_url(self, channel: str) -> str:
680 """Make a ZeroMQ URL for a given channel."""
681 transport = self.transport
682 ip = self.ip
683 port = getattr(self, "%s_port" % channel)
684
685 if transport == "tcp":
686 return "tcp://%s:%i" % (ip, port)
687 else:
688 return f"{transport}://{ip}-{port}"
689
690 def _create_connected_socket(
691 self, channel: str, identity: bytes | None = None
692 ) -> zmq.sugar.socket.Socket:
693 """Create a zmq Socket and connect it to the kernel."""
694 url = self._make_url(channel)
695 socket_type = channel_socket_types[channel]
696 self.log.debug("Connecting to: %s", url)
697 sock = self.context.socket(socket_type)
698 # set linger to 1s to prevent hangs at exit
699 sock.linger = 1000
700 if identity:
701 sock.identity = identity
702 if self.curve_publickey is not None:
703 # The connection file already carries this keypair, so reusing it
704 # avoids introducing an additional key-distribution mechanism here.
705 # curve_serverkey authenticates the server; the keypair configures
706 # encrypted communication for the client socket.
707 sock.curve_secretkey = self.curve_secretkey
708 sock.curve_publickey = self.curve_publickey
709 sock.curve_serverkey = self.curve_publickey
710 sock.connect(url)
711 return sock
712
713 def connect_iopub(self, identity: bytes | None = None) -> zmq.sugar.socket.Socket:
714 """return zmq Socket connected to the IOPub channel"""
715 sock = self._create_connected_socket("iopub", identity=identity)
716 sock.setsockopt(zmq.SUBSCRIBE, b"")
717 return sock
718
719 def connect_shell(self, identity: bytes | None = None) -> zmq.sugar.socket.Socket:
720 """return zmq Socket connected to the Shell channel"""
721 return self._create_connected_socket("shell", identity=identity)
722
723 def connect_stdin(self, identity: bytes | None = None) -> zmq.sugar.socket.Socket:
724 """return zmq Socket connected to the StdIn channel"""
725 return self._create_connected_socket("stdin", identity=identity)
726
727 def connect_hb(self, identity: bytes | None = None) -> zmq.sugar.socket.Socket:
728 """return zmq Socket connected to the Heartbeat channel"""
729 return self._create_connected_socket("hb", identity=identity)
730
731 def connect_control(self, identity: bytes | None = None) -> zmq.sugar.socket.Socket:
732 """return zmq Socket connected to the Control channel"""
733 return self._create_connected_socket("control", identity=identity)
734
735
736class LocalPortCache(SingletonConfigurable):
737 """
738 Used to keep track of local ports in order to prevent race conditions that
739 can occur between port acquisition and usage by the kernel. All locally-
740 provisioned kernels should use this mechanism to limit the possibility of
741 race conditions. Note that this does not preclude other applications from
742 acquiring a cached but unused port, thereby re-introducing the issue this
743 class is attempting to resolve (minimize).
744 See: https://github.com/jupyter/jupyter_client/issues/487
745 """
746
747 def __init__(self, **kwargs: Any) -> None:
748 super().__init__(**kwargs)
749 self.currently_used_ports: set[int] = set()
750
751 def find_available_port(self, ip: str) -> int:
752 while True:
753 tmp_sock = socket.socket()
754 tmp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, b"\0" * 8)
755 tmp_sock.bind((ip, 0))
756 port = tmp_sock.getsockname()[1]
757 tmp_sock.close()
758
759 # This is a workaround for https://github.com/jupyter/jupyter_client/issues/487
760 # We prevent two kernels to have the same ports.
761 if port not in self.currently_used_ports:
762 self.currently_used_ports.add(port)
763 return port
764
765 def return_port(self, port: int) -> None:
766 if port in self.currently_used_ports: # Tolerate uncached ports
767 self.currently_used_ports.remove(port)
768
769
770__all__ = [
771 "KernelConnectionInfo",
772 "LocalPortCache",
773 "find_connection_file",
774 "tunnel_to_kernel",
775 "write_connection_file",
776]