Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions loopx/control_plane/effect_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,11 @@ def _start_runtime(*, fingerprint: str, info_path: Path) -> dict[str, Any]:
stderr=startup_stderr,
start_new_session=os.name != "nt",
close_fds=True,
# The host outlives the command that started it. On Windows
# a live child whose working directory is the caller's
# directory keeps that directory (and its ancestors) from
# being renamed or removed, so the host must not inherit it.
cwd=tempfile.gettempdir(),
)
except PermissionError as exc:
raise EffectRuntimeHostPermissionError() from exc
Expand Down
26 changes: 25 additions & 1 deletion loopx/usage_ping.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import shutil
import subprocess
import sys
import tempfile
import time
from contextvars import ContextVar
from datetime import datetime, timezone
Expand Down Expand Up @@ -199,9 +200,32 @@ def observe_verified_return() -> None:
pass


# Request fields that name a caller-owned filesystem path. The child runs in a
# stable directory instead of the caller's, so a relative value must be fixed
# here or it would resolve against the wrong directory.
_CALLER_PATH_FIELDS = ("path", "registry", "runtime")


def _pin_caller_paths(request: dict[str, Any]) -> dict[str, Any]:
"""Freeze caller-owned request paths before the child loses the caller cwd."""
pinned = dict(request)
for field in _CALLER_PATH_FIELDS:
value = pinned.get(field)
if isinstance(value, str) and value:
pinned[field] = str(Path(value).expanduser().resolve())
return pinned


def _detach(request: dict[str, Any], *, command: list[str] | None = None) -> None:
request = _pin_caller_paths(request)
kwargs: dict[str, Any] = {"stdin": subprocess.PIPE, "stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL, "close_fds": True}
"stderr": subprocess.DEVNULL, "close_fds": True,
# The sender outlives the command. On Windows a
# live child whose working directory is the
# caller's directory keeps that directory (and its
# ancestors) from being renamed or removed, so the
# child must not inherit it.
"cwd": tempfile.gettempdir()}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 移开 cwd 前保留观察器的相对路径语境。在 observe_quota_cycle 派发之前按调用者 cwd 固定 registry_path 和 runtime_root(同时审查 request 中其余路径),继续让两个后台进程使用稳定临时目录;补相对 registry/runtime 的真实 detached 绑定和周期读回测试。 独立观察:真实存活进程的 cwd 已从调用者目录改到临时目录;同时,实际 detached Python→TypeScript 链中,base 的相对和绝对路径均记录 quota_cycle 与 codex_turn,当前源码、普通 wheel 和 sdist 的相对路径只记录 unknown 的 quota_cycle,绝对路径对照正常。 验证要求:用真实 detached Python→TypeScript、合成合法绑定及只读 SQLite/session 文件,比较相对/绝对 registry 和 runtime 的实际 quota_cycle/codex_turn 聚合;关闭统计仍应零子进程,后台 cwd 继续为稳定目录。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on add1d0603 (pushed after your review).

I reproduced the finding on this head before changing anything. tests/test_usage_goal.py::test_caller_relative_registry_and_runtime_reach_the_detached_observer drives the real observe_quota_cycle → detached python -m loopx.usage_goal → detached TypeScript chain over a read-only SQLite/session fixture, with monkeypatch.chdir to the caller directory and Path("registry.json") / Path("runtime") as the caller's arguments. Without the pinning change it fails with exactly the signature you described (no bound turn, only the unbound cycle):

>       pytest.fail("detached timing did not reach TS owner")
E       Failed: detached timing did not reach TS owner
1 failed in 9.71s

With the change: 1 passed in 6.01s, both quota_cycle and codex_turn, host codex_app.

The scope is wider than the explicit relative flag: the default CLI registry is .loopx/registry.json (loopx/paths.py::default_registry_path), and resolve_runtime_root keeps a --runtime-root / common_runtime_root value relative to the project registry root, so ordinary project-local quota cycles already carried a relative registry and runtime root. Your reading of the failure mode (binding exception swallowed by the existing best-effort branch, cycle still recorded as unknown) matches the aggregator records.

What changed, and why at this layer: _detach is the boundary that moves the child out of the caller's directory, so it now freezes every caller-owned path in the request (path, registry, runtime) with Path(value).expanduser().resolve() before dispatch (_pin_caller_paths). The two helpers keep the stable OS temp cwd; the quota observer keeps its binding for relative and absolute arguments; and the other _detach callers (begin / finish, observe_verified_return, the Goal observer) get the same guarantee, so no caller can hand a child a path that only resolved while it inherited the caller's cwd. The path (state file) field already comes from Path.home(), but it is pinned too so the rule is uniform rather than caller-specific. Pinning inside observe_quota_cycle alone would have left the path field and the other three callers on the old implicit contract.

Evidence on this head (native Windows):

  • tests/test_usage_goal.py: 10 passed in 21.00s — absolute and caller-relative registry/runtime variants both retain the bound turn, numeric-timing and long-header variants unchanged.
  • tests/test_usage_ping.py + tests/control_plane/test_effect_runtime_listener_startup.py: 36 passed, 10 failed. The ten are the pre-existing host-locale failures; reverting only the pinning change on this machine reproduces the same ten names (tests/test_usage_ping.py: 31 passed / 10 failed), so the follow-up adds no host failure.
  • ruff check clean on the changed files; canary premerge direct checks (diff hygiene, changed-file py_compile, module-ceiling colocation) passed; the semantic-vocabulary drift canary passes once this checkout's npm dev dependencies are installed (the first failure was the missing typescript package, not the diff). The two remaining risk-profile smokes (catalog-planner, pytest-smoke-suite-facade) fail for the unchanged Windows fixture-cleanup class: the planner smoke itself exits 0 with catalog-canary-planner-smoke ok.

Unchanged, per your scope note: the seven other spawn candidates, the TypeScript entries (process.cwd appears nowhere under loopx/), and the pre-existing WinError 5 cleanup class. I do not claim native Windows deletion, wheel/sdist installs, or hosted CI beyond what is listed above.

One CI note for the record: shard 6 on the previous head failed test_real_detached_cycle_and_bound_codex_event_reach_shared_ts_aggregator at its 8s deadline, which is the unbound-cursor wait. On that same head test_bound_session_with_a_long_metadata_header_still_reaches_ts, which drives the identical detached Python → TypeScript chain, passed in 1.5s, and main's own shards are red for unrelated tests, so I read it as a scheduling flake rather than a break in the chain; this head is re-running and I will report what it shows.

English verdict: the P2 is confirmed and fixed on add1d0603 — caller-owned paths are pinned before dispatch, the stable helper cwd is unchanged, and a real caller-relative registry/runtime regression covers it.

结论:P2 已复现并修复(add1d0603)。在离开调用者目录之前按调用者 cwd 固定 registry / runtime(以及状态文件 path),后台进程仍使用稳定临时目录;新增真实相对路径的分离 Python → TypeScript 回归,修复前同一测试失败、修复后保留 codex_turn。合并权限与最终评审仍在维护者。

if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
else:
Expand Down
39 changes: 39 additions & 0 deletions tests/control_plane/test_effect_runtime_listener_startup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import subprocess
import tempfile
from pathlib import Path

Expand Down Expand Up @@ -58,3 +59,41 @@ def test_listener_failure_preserves_safe_diagnostic_and_retry_boundary(
runtime_dir = effect_runtime._runtime_dir()
assert not list(runtime_dir.glob("runtime-*.json"))
assert not list(runtime_dir.glob("start-*.lock"))


def test_runtime_host_spawn_leaves_the_callers_directory_free(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
for variable in ("TMPDIR", "TEMP", "TMP"):
monkeypatch.setenv(variable, str(tmp_path))
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
hook = tmp_path / "denied-listener.mjs"
hook.write_text(
'import { Server } from "node:net";\n'
'Server.prototype.listen = function () {\n'
' process.nextTick(() => this.emit("error", Object.assign(\n'
' new Error("private-listener-fixture"),\n'
' { code: "EPERM", syscall: "listen" })));\n'
' return this;\n'
'};\n',
encoding="utf-8",
)
monkeypatch.setenv("NODE_OPTIONS", f"--import={hook.as_uri()}")
recorded: list[dict[str, object]] = []
original = subprocess.Popen

def record(args, *rest, **kwargs):
if "effect_runtime_server.ts" in str(args):
recorded.append(kwargs)
return original(args, *rest, **kwargs)

monkeypatch.setattr(effect_runtime.subprocess, "Popen", record)

with pytest.raises(effect_runtime.EffectRuntimeStartupError):
effect_runtime.effect_runtime_result("runtime.ping", {})

# The host outlives the command; on Windows an inherited caller directory
# stays locked for the whole host lifetime.
assert len(recorded) == 1
assert Path(str(recorded[0]["cwd"])).resolve() == tmp_path.resolve()
24 changes: 21 additions & 3 deletions tests/test_usage_goal.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,15 @@ def test_metadata_cannot_redirect_timing_read_outside_selected_home(tmp_path, mo
assert usage_goal._bound_codex_session(registry, "goal", "agent") is None


def _bound_cycle_through_detached_ts(tmp_path, monkeypatch, *, header_characters: int = 0, numeric_seconds: bool = False):
def _bound_cycle_through_detached_ts(tmp_path, monkeypatch, *, header_characters: int = 0, numeric_seconds: bool = False,
caller_relative_paths: bool = False):
"""Drive the real quota observer -> detached TS chain over one bound session.

`header_characters` widens the session_meta line the way Codex's recorder
does with base_instructions, so the same entry point covers a header that
does not fit in one fixed read.
does not fit in one fixed read. `caller_relative_paths` names the registry
and runtime root the way an ordinary `loopx --registry .loopx/registry.json`
call does: relative to the caller, not to the detached child.
"""
from datetime import datetime, timezone
from pathlib import Path
Expand All @@ -188,8 +191,15 @@ def wait_for(predicate):
pytest.fail("detached timing did not reach TS owner")

now = time.time_ns() // 1_000_000
registry_path, runtime_root = registry, tmp_path / "runtime"
if caller_relative_paths:
# The observer is a caller: its paths are relative to this directory,
# while the detached child runs in the stable OS temp directory.
monkeypatch.chdir(tmp_path)
registry_path, runtime_root = Path("registry.json"), Path("runtime")

def publish(phase):
usage_goal.observe_quota_cycle(registry_path=registry, runtime_root=tmp_path / "runtime", goal_id="goal",
usage_goal.observe_quota_cycle(registry_path=registry_path, runtime_root=runtime_root, goal_id="goal",
agent_id="agent", turn_id="turn-a", phase=phase,
at=time.time_ns() // 1_000_000, host="unknown")
publish("start")
Expand Down Expand Up @@ -226,6 +236,14 @@ def test_real_detached_cycle_and_bound_codex_event_reach_shared_ts_aggregator(tm
assert not cycle_path.exists() and not goals.exists()


def test_caller_relative_registry_and_runtime_reach_the_detached_observer(tmp_path, monkeypatch):
"""Leaving the caller's directory must not re-resolve the caller's own paths."""
preview, _, _, _ = _bound_cycle_through_detached_ts(tmp_path, monkeypatch, caller_relative_paths=True)
assert {row["measurement"] for row in preview["counters"]} == {"quota_cycle", "codex_turn"}
assert all(row["host"] == "codex_app" for row in preview["counters"])
usage_ping.control("disable")


def test_provider_numeric_timing_reaches_real_detached_installation_aggregator(tmp_path, monkeypatch):
from pathlib import Path
preview, _, _, _ = _bound_cycle_through_detached_ts(tmp_path, monkeypatch, numeric_seconds=True)
Expand Down
20 changes: 20 additions & 0 deletions tests/test_usage_ping.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import socket
import subprocess
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
Expand Down Expand Up @@ -366,6 +367,25 @@ def test_detached_sender_honors_proxy_and_no_proxy(isolated, collector, monkeypa
release.set()


def test_detached_sender_leaves_the_callers_directory_free(isolated, tmp_path):
"""On Windows a live child whose cwd is the caller's directory pins it."""
observed = tmp_path / 'sender-cwd.json'
code = ("import json, os, sys\n"
"json.load(sys.stdin)\n"
f"open({str(observed)!r}, 'w', encoding='utf-8').write(json.dumps(os.getcwd()))\n")
usage_ping._detach(usage_ping._request('observe', isolated / 'usage-ping.json'),
command=[sys.executable, '-c', code])
deadline = time.monotonic() + 30
child_cwd = None
while time.monotonic() < deadline and child_cwd is None:
try:
child_cwd = json.loads(observed.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
time.sleep(0.05)
assert child_cwd is not None, 'detached sender did not report its working directory'
assert Path(child_cwd).resolve() == Path(tempfile.gettempdir()).resolve()


@pytest.mark.parametrize('upgrade', [False, True])
def test_real_chat_settings_share_cli_choice_and_reject_cross_origin(isolated, upgrade):
if upgrade:
Expand Down
Loading