Skip to content

Commit 7caed66

Browse files
A malformed marker crashed the courtesy URL, and the hint guessed the port (#93)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 63ff75e commit 7caed66

2 files changed

Lines changed: 130 additions & 5 deletions

File tree

grapharc/cli/plan.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,22 @@ def watch_url(trace_path: Path, *, run_id: str | None = None, timeout: float = 0
107107
marker = Path(".grapharc") / "live-server.json"
108108
try:
109109
record = json.loads(marker.read_text(encoding="utf-8"))
110+
# TypeError is in the net for the marker shapes JSON allows but this
111+
# code does not: a non-object document (indexing a list raises it) and
112+
# a null port (`int(None)`). A malformed marker must degrade to the
113+
# hint, never escape as a traceback from a command that only wanted to
114+
# print a courtesy URL.
110115
root = Path(record["live_root"])
111116
base = str(record["url"])
112117
host, port = str(record["host"]), int(record["port"])
113-
except (OSError, ValueError, KeyError):
118+
except (OSError, ValueError, KeyError, TypeError):
114119
return None
115120
try:
116-
rel = trace_path.resolve().relative_to(root)
117-
except ValueError:
121+
# The marker's root is resolved by the serve that wrote it; resolve it
122+
# again here so a hand-edited or symlinked root still matches the same
123+
# directory instead of failing the lexical comparison.
124+
rel = trace_path.resolve().relative_to(root.resolve())
125+
except (ValueError, OSError):
118126
return None
119127
try:
120128
with socket.create_connection((host, port), timeout=timeout):
@@ -127,22 +135,42 @@ def watch_url(trace_path: Path, *, run_id: str | None = None, timeout: float = 0
127135
return url
128136

129137

138+
def _marker_base() -> str:
139+
"""The last-known server base URL, from the marker; the default otherwise.
140+
141+
A marker that exists but whose server stopped answering still names the
142+
host and port the operator actually uses — an instruction quoting a
143+
different port than their `grapharc serve` command is a wrong instruction.
144+
Read with the same tolerance as `watch_url`: any defect means the default.
145+
"""
146+
import json
147+
148+
try:
149+
record = json.loads(
150+
(Path(".grapharc") / "live-server.json").read_text(encoding="utf-8")
151+
)
152+
return str(record["url"]).rstrip("/")
153+
except (OSError, ValueError, KeyError, TypeError):
154+
return "http://127.0.0.1:8000"
155+
156+
130157
def watch_hint(trace_path: Path) -> str:
131158
"""What to print when no live server answers: the command, then the URL.
132159
133160
The user asked for the link to always exist — so when it cannot be exact,
134161
it is an instruction that produces the exact one.
135162
"""
163+
base = _marker_base()
136164
try:
137165
rel = trace_path.resolve().relative_to((Path(".grapharc") / "runs").resolve())
138166
from urllib.parse import quote
139167

140-
would_be = f"http://127.0.0.1:8000/live/view?trace={quote(rel.as_posix(), safe='')}"
168+
would_be = f"{base}/live/view?trace={quote(rel.as_posix(), safe='')}"
141169
return f"run `grapharc serve --live-root .grapharc/runs` then open {would_be}"
142170
except ValueError:
143171
return (
144172
f"run `grapharc serve --live-root {trace_path.parent}` "
145-
f"then open http://127.0.0.1:8000/live"
173+
f"then open {base}/live"
146174
)
147175

148176

tests/test_cli.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2039,6 +2039,103 @@ def test_a_stale_marker_with_no_listener_falls_back_to_the_hint(
20392039
assert payload["watch_url"] is None
20402040

20412041

2042+
def test_a_malformed_marker_degrades_to_the_hint_instead_of_crashing(
2043+
tmp_path, monkeypatch
2044+
):
2045+
"""The marker shapes JSON allows but the reader does not: null port, a
2046+
non-object document. Both used to escape `watch_url` as a TypeError
2047+
traceback from a command that only wanted to print a courtesy URL."""
2048+
from grapharc.cli.plan import watch_url
2049+
2050+
monkeypatch.chdir(tmp_path)
2051+
marker = tmp_path / ".grapharc" / "live-server.json"
2052+
marker.parent.mkdir(parents=True, exist_ok=True)
2053+
trace = tmp_path / ".grapharc" / "runs" / "r1" / "trace.jsonl"
2054+
trace.parent.mkdir(parents=True, exist_ok=True)
2055+
trace.write_text("", encoding="utf-8")
2056+
2057+
marker.write_text(
2058+
json.dumps(
2059+
{
2060+
"url": "http://127.0.0.1:8000",
2061+
"host": "127.0.0.1",
2062+
"port": None,
2063+
"live_root": str((tmp_path / ".grapharc" / "runs").resolve()),
2064+
}
2065+
),
2066+
encoding="utf-8",
2067+
)
2068+
assert watch_url(trace) is None
2069+
2070+
marker.write_text(json.dumps(["not", "an", "object"]), encoding="utf-8")
2071+
assert watch_url(trace) is None
2072+
2073+
2074+
def test_an_unresolved_marker_root_still_matches_through_a_symlink(
2075+
tmp_path, monkeypatch
2076+
):
2077+
"""`serve` writes its root resolved; a hand-edited marker may not be. The
2078+
comparison resolves both sides now, so a symlinked spelling of the same
2079+
directory is the same directory rather than a lexical mismatch."""
2080+
import socket
2081+
2082+
from grapharc.cli.plan import watch_url
2083+
2084+
monkeypatch.chdir(tmp_path)
2085+
real = tmp_path / "real-runs"
2086+
real.mkdir()
2087+
link = tmp_path / "link-runs"
2088+
link.symlink_to(real, target_is_directory=True)
2089+
trace = real / "r1" / "trace.jsonl"
2090+
trace.parent.mkdir(parents=True)
2091+
trace.write_text("", encoding="utf-8")
2092+
2093+
listener = socket.socket()
2094+
listener.bind(("127.0.0.1", 0))
2095+
listener.listen(1)
2096+
port = listener.getsockname()[1]
2097+
try:
2098+
marker = tmp_path / ".grapharc" / "live-server.json"
2099+
marker.parent.mkdir(parents=True, exist_ok=True)
2100+
marker.write_text(
2101+
json.dumps(
2102+
{
2103+
"url": f"http://127.0.0.1:{port}",
2104+
"host": "127.0.0.1",
2105+
"port": port,
2106+
"live_root": str(link), # deliberately unresolved
2107+
}
2108+
),
2109+
encoding="utf-8",
2110+
)
2111+
url = watch_url(trace)
2112+
finally:
2113+
listener.close()
2114+
assert url is not None and "trace=r1%2Ftrace.jsonl" in url
2115+
2116+
2117+
def test_the_hint_quotes_the_marker_port_when_the_server_is_down(
2118+
tmp_path, monkeypatch, capsys
2119+
):
2120+
"""A stale marker still names the port the operator actually serves on;
2121+
an instruction quoting a different port than their own `grapharc serve`
2122+
command is a wrong instruction."""
2123+
import socket
2124+
2125+
monkeypatch.chdir(tmp_path)
2126+
probe = socket.socket()
2127+
probe.bind(("127.0.0.1", 0))
2128+
dead_port = probe.getsockname()[1]
2129+
probe.close()
2130+
_write_live_marker(tmp_path, port=dead_port)
2131+
code = main(["plan", "look into it", "--scripted"])
2132+
printed = capsys.readouterr().out
2133+
assert code == 0
2134+
watch = next(line for line in printed.splitlines() if line.startswith("watch"))
2135+
assert "grapharc serve --live-root .grapharc/runs" in watch
2136+
assert f"127.0.0.1:{dead_port}/live/view?trace=" in watch
2137+
2138+
20422139
def test_a_trace_outside_the_live_root_gets_no_exact_url(tmp_path, monkeypatch, capsys):
20432140
import socket
20442141

0 commit comments

Comments
 (0)