Skip to content

Commit

Permalink
Reworking view (#878)
Browse files Browse the repository at this point in the history
* Add method to get our global configs path

* Add utilities for managing the list of active servers

* Add basic start-server functionality

* Start calling the new server functionality

* ensure servers start and stop correctly

---------

Co-authored-by: Oscar Levin <oscar.levin@unco.edu>
  • Loading branch information
skiadas and oscarlevin authored Dec 18, 2024
1 parent 42f1c7f commit 4ac3142
Show file tree
Hide file tree
Showing 4 changed files with 270 additions and 52 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ Instructions: Add a subsection under `[Unreleased]` for additions, fixes, change

## [Unreleased]

### Changed

- `pretext view` now reuses the current local server correctly, and starts different local servers for different projects. This allows you to correctly view multiple projects at the same time.

## [2.10.1] - 2024-12-10

Expand Down
93 changes: 44 additions & 49 deletions pretext/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
core,
constants,
plastex,
server,
VERSION,
CORE_COMMIT,
)
Expand Down Expand Up @@ -783,18 +784,23 @@ def view(
"""

# pretext view -s should immediately stop the server and do nothing else.
if utils.cannot_find_project(task="view the output for"):
return
project = Project.parse()

if stop_server:
try:
projectHash = utils.hash_path(project.abspath())
current_server = server.active_server_for_path_hash(projectHash)
log.info("\nStopping server.")
utils.stop_server()
if current_server:
current_server.terminate()
except Exception as e:
log.warning("Failed to stop server.")
log.debug(e, exc_info=True)
finally:
return
if utils.cannot_find_project(task="view the output for"):
return
project = Project.parse()

try:
target = project.get_target(name=target_name, log_info_for_none=not stage)
except AssertionError as e:
Expand Down Expand Up @@ -848,8 +854,26 @@ def view(
webbrowser.open(url)
return
# Start server if there isn't one running already:
used_port = utils.active_server_port()
if restart_server or (port != used_port) or (used_port is None):
projectHash = utils.hash_path(project.abspath())
current_server = server.active_server_for_path_hash(projectHash)
if restart_server and current_server is not None:
log.info(
f"Terminating existing server {current_server.pid} on port {current_server.port}"
)
current_server.terminate()
current_server = None
# Double check that the current server really is active:
if current_server is not None and current_server.isActiveServer():
url_base = utils.url_for_access(access=access, port=current_server.port)
url = url_base + url_path
log.info(f"Server is already available at {url_base}")
if no_launch:
log.info(f"The {target_name} is available at {url}")
else:
log.info(f"Now opening browser for {target_name} at {url}")
webbrowser.open(url)
# otherwise, start a server
else:
log.info(
f"Now preparing local server to preview your project directory `{project.abspath()}`."
)
Expand All @@ -861,50 +885,21 @@ def view(
)
log.info(" personal computer.)")
log.info("")
# First terminate any existing server using this port
if used_port == port:
try:
utils.stop_server(used_port)
except Exception as e:
log.warning("Failed to stop server.")
log.debug(e, exc_info=True)
pass
# Start the new server
server = project.server_process(
access=access,
port=port,
)
server.start()

# Now get the updated port in case we had to pick a new one
actual_port = utils.active_server_port() or port
# set the url
url_base = utils.url_for_access(access=access, port=actual_port)
url = url_base + url_path
log.info(f"Server will soon be available at {url_base}")
if no_launch:
log.info(f"The {target_name} will be available at {url}")
else:
# SECONDS = 2
log.info(f"Opening browser for {target_name} at {url}")
# time.sleep(SECONDS)
webbrowser.open(url)
try:
while server.is_alive():
time.sleep(1)
except KeyboardInterrupt:
log.info("Stopping server.")
server.terminate()
return
else:
url_base = utils.url_for_access(access=access, port=used_port)
url = url_base + url_path
log.info(f"Server is already available at {url_base}")
if no_launch:
log.info(f"The {target_name} is available at {url}")
else:
log.info(f"Now opening browser for {target_name} at {url}")
webbrowser.open(url)
def callback(actual_port: int) -> None:
url_base = utils.url_for_access(access=access, port=actual_port)
url = url_base + url_path
log.info(f"Server will soon be available at {url_base}")
if no_launch:
log.info(f"The {target_name} will be available at {url}")
else:
# SECONDS = 2
log.info(f"Opening browser for {target_name} at {url}")
# time.sleep(SECONDS)
webbrowser.open(url)

log.info("starting server ...")
server.start_server(project.abspath(), access, port, callback)


# pretext deploy
Expand Down
201 changes: 201 additions & 0 deletions pretext/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
from __future__ import annotations
from dataclasses import dataclass
from http.server import SimpleHTTPRequestHandler
import logging
import os
from pathlib import Path
import socketserver
import typing as t
import psutil

from pretext.utils import hash_path, home_path

# Get access to logger
log = logging.getLogger("ptxlogger")


@dataclass
class RunningServerInfo:
"""A simple dataclass to hold the information in the running servers file."""

pathHash: str
pid: int
port: int
binding: str

@staticmethod
def fromFileLine(line: str) -> RunningServerInfo:
(pathHash, pid, port, binding) = line.split()
return RunningServerInfo(
pathHash=pathHash, pid=int(pid), port=int(port), binding=binding
)

def toFileLine(self) -> str:
return f"{self.pathHash} {self.pid} {self.port} {self.binding}\n"

def isActiveServer(self) -> bool:
"""Returns whether the server represented by this object is active on the provided port"""
p = psutil.Process(self.pid)
if not p.is_running():
log.info(f"Found entry no longer running {p.pid}")
return False
if p.status == psutil.STATUS_ZOMBIE:
log.info(f"Found zombie process {p.pid}")
return False
for _, _, _, laddr, _, _ in p.net_connections("all"):
if laddr.port == self.port:
log.info(f"Found server at {self.url()}")
return True
log.info(
f"Found process {self.pid} no longer listening on specified port {self.port}"
)
return False

def terminate(self) -> None:
"""Attempt to terminate the process described by this info."""
try:
log.info(f"Terminating {self.pid}")
psutil.Process(self.pid).terminate()
remove_server_entry(self.pathHash)
except Exception as e:
log.info(f"Terminate failed for {self.pid}.")
log.exception(e, exc_info=True)

def url(self) -> str:
return f"{self.binding}:{self.port}"


def get_running_servers() -> t.List[RunningServerInfo]:
"""
Processes the ~/.ptx/running_servers file to retrieve a list
of any possibly current running servers.
"""
if not home_path().exists():
return []
try:
runningServersFile = home_path() / "running_servers"
if not runningServersFile.is_file():
return []
with open(runningServersFile, "r") as f:
return [RunningServerInfo.fromFileLine(line) for line in f.readlines()]
except IOError as e:
log.info("Unable to open running servers file.")
log.exception(e, exc_info=True)
return []


def save_running_servers(runningServers: t.List[RunningServerInfo]) -> None:
"""
Overwrites the ~/.ptx/running_servers file to store
the new list of running servers.
"""
# Ensure home path exists
os.makedirs(home_path(), exist_ok=True)
try:
runningServersFile = home_path() / "running_servers"
with open(runningServersFile, "w") as f:
# Write each server info to a new line
f.writelines([info.toFileLine() for info in runningServers])
except IOError as e:
log.info("Unable to write running servers file.")
log.exception(e, exc_info=True)


def add_server_entry(pathHash: str, pid: int, port: int, binding: str) -> None:
"""Add a new server entry to ~/.ptx/running_servers.
This function does not attempt to ensure that an active server doesn't already exist.
"""
PURGE_LIMIT = 10 # If more servers active, try to clean up
runningServers = get_running_servers()
newEntry = RunningServerInfo(pathHash=pathHash, pid=pid, port=port, binding=binding)
runningServers.append(newEntry)
if len(runningServers) >= PURGE_LIMIT:
log.info(f"There are {PURGE_LIMIT} or more servers on file. Cleaning up ...")
runningServers = list(stop_inactive_servers(runningServers))
save_running_servers(runningServers)
log.info(f"Added server entry {newEntry.toFileLine()}")


def remove_server_entry(pathHash: str) -> None:
remainingServers = [
info for info in get_running_servers() if info.pathHash != pathHash
]
save_running_servers(remainingServers)


def stop_inactive_servers(
servers: t.List[RunningServerInfo],
) -> t.Iterator[RunningServerInfo]:
"""Stops any inactive servers and yields the active ones."""
for server in servers:
if server.isActiveServer():
yield server
else:
server.terminate()


def active_server_for_path_hash(pathHash: str) -> t.Optional[RunningServerInfo]:
return next(
(info for info in get_running_servers() if info.pathHash == pathHash),
None,
)


# boilerplate to prevent overzealous caching by preview server, and
# avoid port issues
def binding_for_access(access: t.Literal["public", "private"] = "private") -> str:
if access == "private":
return "localhost"
return "0.0.0.0"


def start_server(
base_dir: Path,
access: t.Literal["public", "private"] = "private",
port: int = 8128,
callback: t.Callable[[int], None] | None = None,
) -> None:
log.info("setting up ...")
pathHash = hash_path(base_dir)
pid = os.getpid()
binding = binding_for_access(access)
log.info("values set...")

# Previously we defined a custom handler to prevent caching, but we don't need to do that anymore. It was causing issues with the _static js/css files inside codespaces for an unknown reason. Might bring this back in the future.
# 2024-04-05: try using this again to let Firefox work
class RequestHandler(SimpleHTTPRequestHandler):
def __init__(self, *args: t.Any, **kwargs: t.Any):
super().__init__(*args, directory=base_dir.as_posix(), **kwargs)

"""HTTP request handler with no caching"""

def end_headers(self) -> None:
self.send_my_headers()
SimpleHTTPRequestHandler.end_headers(self)

def send_my_headers(self) -> None:
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")

class TCPServer(socketserver.TCPServer):
allow_reuse_address = True

while True:
try:
with TCPServer((binding, port), RequestHandler) as httpd:
log.info("adding server entry")
add_server_entry(pathHash, pid, port, binding)
log.info("Starting the server")
if callback is not None:
callback(port)
httpd.serve_forever()
except OSError:
log.warning(f"Port {port} could not be used.")
port += 1
log.warning(f"Trying port {port} instead.\n")
except KeyboardInterrupt:
log.info("Stopping server.")
remove_server_entry(pathHash)
return
Loading

0 comments on commit 4ac3142

Please sign in to comment.