Try an interactive version of this dialog: Sign up at solve.it.com, click Upload, and pass this URL.

from fasthtml.common import *
from fasthtml.jupyter import *
from fastcore.utils import *
import socket, time

Reproducing and fixing JupyUvi.stop() port races

This notebook shows why restarting a FastHTML/Jupyter uvicorn server can intermittently fail, and gives a small, testable fix.

The main issues identified and fixed are:

  1. Wrong host in port checksJupyUvi binds to 0.0.0.0, but the port-free check was not using the same host, causing false "port is free" results.
  2. No timeout/error on stop() — The original stop() didn't wait for the port to actually be released, so a restart immediately after could race with the old server still shutting down.
  3. Too-permissive port check (SO_REUSEADDR) — The original check set SO_REUSEADDR and only called bind, which can report a port as free even when a server is still winding down.
  4. No listen() call in port probe — A proper server does both bind and listen; the original check skipped listen, making it less representative of whether uvicorn could actually start.

1. Stop not passing host to wait_port_free

wait_port_free accepts a port & host, but JupyUvi.stop does not pass host along. By default JupyUvi binds to interface 0.0.0.0.

Binding to 0.0.0.0 means listen to all IPv4 interfaces. Binding to localhost means listen only on the loopback interface.

Because of that asymmetry bind("0.0.0.0", port) needs the port to be available across all IPv4 interfaces, whereas bind("localhost", port) only tests loopback.

wait_port_free??
def wait_port_free(port, host='localhost', max_wait=20):
    "Wait for `port` to be free on `host`"
    start = time.time()
    while not is_port_free(port, host):
        if time.time() - start > max_wait: raise TimeoutError(f"Port {host}:{port} not free after {max_wait}s")
        time.sleep(0.1)

File: ~/ws/fasthtml/fasthtml/jupyter.py

JupyUvi??
class JupyUvi:
    "Start and stop a Jupyter compatible uvicorn server with ASGI `app` on `port` with `log_level`"
    def __init__(self, app, log_level="error", host='0.0.0.0', port=8000, start=True, live=False, live_rt='/_lr', daemon=False, **kwargs):
        self.kwargs = kwargs
        store_attr(but='start,live')
        self.server = None
        self._live_ver = 0
        if live: self._setup_live(app)
        if start: self.start()
        if not os.environ.get('IN_SOLVEIT'): htmx_config_port(port)

    def start(self):
        self.server = nb_serve(self.app, log_level=self.log_level, host=self.host, port=self.port,daemon=self.daemon, **self.kwargs)

    def stop(self):
        self.server.should_exit = True
        wait_port_free(self.port, self.host)

    def _setup_live(self, app):
        rt = self.live_rt or '/_lr'
        if not rt.startswith('/'): rt = f'/{rt}'
        app.hdrs.append(Script(f"new EventSource({rt!r}).onmessage=e=>{{if(e.data==='reload')navigation.reload()}}"))
        @app.get(rt)
        async def _sse(): return EventStream(self._live_sse())
        get_ipython().events.register('post_run_cell', lambda _: setattr(self, '_live_ver', self._live_ver+1))

    async def _live_sse(self):
        ver = self._live_ver
        while not self.server.should_exit:
            await asyncio.sleep(0.1)
            if ver != self._live_ver:
                ver = self._live_ver
                yield 'data: reload\n\n'

File: ~/ws/fasthtml/fasthtml/jupyter.py

ip = socket.gethostbyname(socket.gethostname())
s = socket.socket(); s.bind((ip,0)); s.listen()
port = s.getsockname()[1]
def ok(h):
    try:
        x = socket.socket(); x.bind((h,port)); x.listen(); x.close()
        return True
    except OSError: return False

ok("127.0.0.1"), ok("0.0.0.0")
(False, False)

In order to demonstrate we will bind a non-loopback IPv4 interface. We use the following trick to get the IP of the non-loopback (the port 80 is just used for getting the IP, not binding anything to it).

t = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
t.connect(("8.8.8.8", 8004))
ip = t.getsockname()[0]
t.close()

ip
'192.168.1.136'

Now we bind that IP to a random port so re-running the notebook won't get any "address already in use errors".

s = socket.socket()
s.bind((ip, 0))
s.listen()
port = s.getsockname()[1]

port
57496
def ok(h, port):
    x = socket.socket()
    try:
        x.bind((h, port))
        x.listen()
        return True
    except OSError:
        return False
    finally:
        x.close()

ok("127.0.0.1", port), ok("0.0.0.0", port)
(True, False)

We can see that effectively checkin if 127.0.0.1 is free returns true, while the actual 0.0.0.0 inteface is not. This is a long way to say we need to pass the host to the wait_port_free from server.stop().

s.close()
JupyUvi.stop??
def stop(self):
    self.server.should_exit = True
    wait_port_free(self.port, self.host)

File: ~/ws/fasthtml/fasthtml/jupyter.py

Is my understanding correct that the machine has only one range of ports but multiple interfaces? For example, you can listen for connections coming from the internet and expose the local port on an interface that listens for outbound connections. But then you cannot bind the same port for listening to localhost connections, is that correct? And is this why you can bind multiple interfaces to the same port but you cannot listen on them simultaneously? Is this correct, or am I misunderstanding?

You’re close, but there are a couple of important corrections.

A machine does not have one single global port namespace in the simple sense. More precisely, listening sockets are distinguished by a tuple roughly like:

protocol, local IP address, local port

For TCP IPv4, that means these are different listening addresses:

127.0.0.1:8000
192.168.1.131:8000
0.0.0.0:8000

But 0.0.0.0 is special: it means “all IPv4 interfaces”. So binding to:

0.0.0.0:8000

claims port 8000 on every IPv4 interface.

That means if you already have:

192.168.1.131:8000

listening, then trying to bind:

0.0.0.0:8000

will usually fail, because 0.0.0.0:8000 includes 192.168.1.131:8000.

But binding these two may be allowed simultaneously:

127.0.0.1:8000
192.168.1.131:8000

because they are different local IP addresses.

So this part:

you cannot bind the same port for listening to localhost connections

Not quite. You can often bind the same port number on localhost and on a non-loopback interface at the same time:

127.0.0.1:8000
192.168.1.131:8000

But you generally cannot also bind:

0.0.0.0:8000

because that overlaps with both.

The key distinction is:

127.0.0.1      = loopback only
192.168.x.x    = one specific network interface/address
0.0.0.0        = all IPv4 addresses/interfaces

So your notebook’s issue is exactly this asymmetry:

JupyUvi binds server to 0.0.0.0:port
wait_port_free checks localhost:port

The check can say “localhost is free” even though 0.0.0.0:port is not free, because some non-loopback interface is still occupying that port.

One more correction:

expose the local port on an interface that listens for outbound connections

Interfaces do not really “listen for outbound connections.” Listening is about incoming connections to a local IP/port. Outbound connections use a local source IP/port too, but that’s a separate case involving ephemeral ports.

The mental model I’d use is:

A listening TCP socket reserves a port on a specific local address.
0.0.0.0 reserves that port on all local IPv4 addresses.
Therefore, checking localhost alone does not prove that 0.0.0.0 is available.

So yes: the core bug diagnosis is right, but the precise reason is not “one port range across all interfaces”; it is “socket binding is per local address + port, and 0.0.0.0 overlaps with every IPv4 interface.”

I assume we try to bind on interface zero because Uvicorn does the same. Is that correct, or why would we want to do that instead of a specific interface?

Yes — in this case you want the probe to use 0.0.0.0 because that is what Uvicorn is actually trying to bind.

If JupyUvi starts Uvicorn with:

host="0.0.0.0"

then the availability check should test the same thing:

bind(("0.0.0.0", port))

Otherwise you can get a false positive: localhost:port may be free, while 0.0.0.0:port is not.

Why use 0.0.0.0 for Uvicorn at all? Usually because in notebook/container/dev-server contexts you want the app reachable from outside the Python process, for example from:

  • the browser environment,
  • another container/service,
  • the host machine,
  • a forwarded/proxied URL.

If Uvicorn binds only to:

127.0.0.1

then it only accepts connections from the same network namespace/machine. In many notebook or container setups, that is too restrictive.

So the rule is:

The port probe should use the same host/interface as the real server.

If JupyUvi(host="127.0.0.1"), check 127.0.0.1.

If JupyUvi(host="0.0.0.0"), check 0.0.0.0.

Can you explain in which situations you, JupyUVI, which actually runs FastHTML apps, would not be okay binding only to localhost?

Binding only to localhost / 127.0.0.1 is okay when the client that needs to connect is in the same network namespace as the server.

It is not okay when the thing trying to open the FastHTML app is “outside” that namespace.

Common cases where JupyUvi(host="127.0.0.1") may fail:

  • Containers: inside Docker, 127.0.0.1 means “inside the container”, not the host machine. Your browser on the host cannot reach it unless there is some special proxying.
  • Remote notebooks: the Python kernel is running on a remote machine, but your browser is local. 127.0.0.1 on the remote machine is not your laptop’s localhost.
  • Cloud notebook/server environments: platforms often expose apps by forwarding traffic to a container/interface. They usually need the app to listen on 0.0.0.0.
  • Other devices or services need access: e.g. testing from your phone, another machine on the LAN, a webhook callback, or another container.
  • Reverse proxies / port forwarders: a proxy may connect via the container/network interface rather than loopback.

The key idea:

127.0.0.1 = only clients from the same network namespace
0.0.0.0   = clients arriving through any IPv4 interface

In local plain Python on your laptop, localhost is often enough. In notebooks, containers, and hosted environments, 0.0.0.0 is often the safer default because the browser/proxy may not be “inside” the same localhost.

Fix 1

@patch
def stop(self: JupyUvi):
    self.server.should_exit = True
    wait_port_free(self.port, self.host)

Issue 2: wait_port_free silently ignores wait failures

The second issue is that wait_port_free just prints a tiemout, so the the caller never knows if it succeeded. We could do either:

  • return true/false and leave it to the caller to decide (in this case server.stop) or
  • raise an Exception.

Given that this is designed for notebooks, and specially happening when having multiple servers you might be re-running from top to bottom, I am inclined to raise an exception so it's easier to quickly filter where the failure is, instead of surfacing it when attempting a new server creation.

Additionally, on experiments run the 3s timeout is far too short. I suggest increasing it to 20s (which arbitrarily seems better after some runs).

Fix 2

def wait_port_free(port, host='localhost', max_wait=20):
    "Wait for `port` to be free on `host`"
    start_time = time.time()
    while not is_port_free(port, host):
        if time.time() - start_time>max_wait: raise TimeoutError()
        time.sleep(0.1)

Issue 3: Too-permissive port check (SO_REUSEADDR)

The original-style check uses SO_REUSEADDR and only calls bind. That can be too permissive for answering “can uvicorn really start here?”. The stricter check avoids SO_REUSEADDR and also calls listen, closer to what a server does.

Glossary of terms useful for this issue:

  • AF_INET: Address Family: Internet — specifically IPv4.

  • SOCK_STREAM: Socket Type: Stream — TCP-style reliable byte stream.

  • SOL_SOCKET: Socket Option Level: Socket — options applying to the socket layer itself.

  • SO_REUSEADDR: Socket Option: Reuse Address — allow reuse of a local address/port in certain conditions.

def port_free_loose(port, host="localhost"):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind((host, port))
        return True
    except OSError as e: return False, repr(e)
    finally: s.close()

def port_free_strict(port, host="localhost"):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        s.bind((host, port))
        s.listen(1)
        return True
    except OSError as e: return False, repr(e)
    finally: s.close()

can you explain what is the difference between the two approaches?

port_free_loose is answer the question:

“Can the kernel create some socket object with this address?”

but what we want to know is:

“Can uvicorn successfully start a normal listening server on this host/port right now?”

  • SO_REUSEADDR makes the probe answer a slightly different, more permissive question.
  • in the loose version we don't try to listen to the port, only bind.

In this code:

sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))

you are telling the OS: “please allow some forms of address reuse.” That can make bind() succeed in edge cases where a normal server startup may still fail or behave differently.

For a port-availability probe, that is risky because it can produce a false free result:

probe says:  port is free
uvicorn says: address already in use

So the stricter probe should avoid SO_REUSEADDR and mimic the thing we care about:

sock.bind((host, port))
sock.listen(1)

The probe should be conservative: it is better to wait a bit longer than to tell JupyUvi.stop() that shutdown finished while the old server is still holding the port.

what is the difference between binding and listening to an address?

bind and listen are two different stages of making a TCP server socket.

bind((host, port)) says:

“Reserve this local address/port for this socket.”

For example:

s.bind(("0.0.0.0", 8000))

means “attach this socket to port 8000 on all IPv4 interfaces.”

But after bind, the socket is not yet accepting incoming TCP connections. It has only claimed the address.

listen() says:

“Turn this bound socket into a passive server socket that can receive incoming connection attempts.”

So the usual TCP server sequence is:

s.bind((host, port))
s.listen()

Then later the server calls accept() to actually take incoming connections from the queue.

A rough analogy:

bind   = reserve a shop address
listen = open the shop for customers to queue outside
accept = invite the next customer inside

For your port-checking issue, bind alone asks:

“Can I reserve this address?”

But Uvicorn needs:

“Can I reserve this address and open it as a server?”

So a stricter port probe should do both bind and listen.

app, rt = fast_app(pico=False)

@rt("/")
def get(): return "ok"
server = JupyUvi(app, port=5002)

port_free_loose(5002), port_free_strict(5002)
(True, (False, "OSError(48, 'Address already in use')"))
port_free_loose(5002, '0.0.0.0'), port_free_strict(5002, '0.0.0.0'), port_free_loose(5002, 'localhost'), port_free_strict(5002, 'localhost')
((False, "OSError(48, 'Address already in use')"),
 (False, "OSError(48, 'Address already in use')"),
 True,
 (False, "OSError(48, 'Address already in use')"))
if 'server' in globals():
    server.stop()
    print('stopped server')
server = JupyUvi(app, port=5002)
stopped server
port_free_loose(5002, '0.0.0.0'), port_free_strict(5002, '0.0.0.0'), port_free_loose(5002, 'localhost'), port_free_strict(5002, 'localhost')
((False, "OSError(48, 'Address already in use')"),
 (False, "OSError(48, 'Address already in use')"),
 True,
 (False, "OSError(48, 'Address already in use')"))

We see above why the current implementation fails (the false). It is trying only localhost:5002 (instead of 0.0.0.0) and it checks if it can bind to it, while uvicorn is trying to listen on 0.0.0.0. Because the check only prints the result by default (which is easy to miss in a long notebook), next attempt to stop & restart the server fails despite the if 'server' in gloabals() guard.

so then our fix to do listen instead of only bind is not very useful right? probably the issue 1 and 2 where the culprits

I think that’s a fair read: Issue 1 and Issue 2 are probably the real culprits for your observed restart race.

The listen() change is still defensible, but it’s more of a correctness/conservatism improvement than likely the main fix.

The important bugs are:

  1. stop() checked the wrong host:
wait_port_free(self.port)

instead of:

wait_port_free(self.port, self.host)

That can definitely say “free” when 0.0.0.0:port is not free.

  1. wait_port_free() only printed timeout and returned anyway.

So even if the port was still occupied after the wait, stop() continued as if shutdown succeeded.

By comparison, bind() vs bind()+listen() is probably less central. Since Uvicorn itself will create a listening server, using listen() in the probe makes the check closer to reality. But if you had to prioritize, I’d say:

must fix: pass host
must fix: raise/return failure on timeout
nice fix: stricter probe with listen()
maybe fix: avoid SO_REUSEADDR in probe

So yes: your intuition is right. listen() is probably not the key culprit; it’s a “make the probe ask the exact question we care about” improvement.

Setup

We start a tiny FastHTML app through JupyUvi. The default host matters: JupyUvi binds to 0.0.0.0, so checks for whether the port is free should use the same host.

# def is_port_free_strict(port, host="0.0.0.0"):
#     s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
#     try:
#         s.bind((host, port))
#         s.listen(1)
#         return True
#     except OSError: return False
#     finally: s.close()

def wait_port_free_strict(port, host="0.0.0.0", max_wait=10):
    start = time.time()
    while not is_port_free(port, host):
        if time.time() - start > max_wait: raise TimeoutError(f"Port {host}:{port} not free after {max_wait}s")
        time.sleep(0.1)

Test stop and restart

This is the operation that was flaky when stop() used the wrong host or a too-loose port check.

server.stop()
port_free_loose(5002), port_free_strict(5002)
(True, True)

Debugging if it still fails

If the strict wait raises, check the real listener from the shell. If a Python process remains, uvicorn did not shut down or an older server was orphaned.

!lsof -nP -iTCP:5002 -sTCP:LISTEN

what's the difference between 1 and 3?

Issue 1 and issue 3 are different failure modes in the port check:

1. Wrong host

This is about where you check.

JupyUvi starts uvicorn on:

host="0.0.0.0"

but wait_port_free() was effectively checking:

host="localhost"

So it could say “the port is free on localhost” even though the port is not free for 0.0.0.0, which is what the next uvicorn start needs.

So issue 1 is: checking the wrong interface.


3. Too-permissive port check

This is about how you check.

The old check did something like:

s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))

That can say “bind succeeded” in cases where a real server startup may still fail or race, especially around shutdown/restart timing. Your stricter version does:

s.bind((host, port))
s.listen(1)

without SO_REUSEADDR, which better represents “could uvicorn actually listen here now?”

So issue 3 is: checking the right interface too optimistically.

Short version:

Issue Problem Example
1 wrong host checking localhost when server needs 0.0.0.0
3 wrong probe strictness using SO_REUSEADDR + bind only instead of bind + listen

can we write a super short repro example like we did for issue 1?

Yes. For issue 3, the shortest repro is probably: create a socket using SO_REUSEADDR, bind/listen on it, then show that a second socket using the same option can bind() but cannot listen().

s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 0)); s.listen()
port = s.getsockname()[1]

Then compare old-style probe vs stricter probe:

port_free_loose(port), port_free_strict(port)

Expected result should be roughly:

(True, False)

That demonstrates the problem very directly: bind() with SO_REUSEADDR can say “yes”, but bind() + listen() says “no, a real server cannot listen here.”

s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 0)); s.listen()
port = s.getsockname()[1]
port_free_loose(port), port_free_strict(port)
(True, (False, "OSError(48, 'Address already in use')"))