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
79 changes: 67 additions & 12 deletions scripts/install
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,57 @@ progress_done() {
printf '✓ %s (%ds)\n' "$1" "$((SECONDS - PROGRESS_STARTED))"
}

trap progress_stop EXIT
trap 'progress_stop; trap - INT; kill -INT $$' INT
trap 'progress_stop; trap - TERM; kill -TERM $$' TERM
# The menu bar companion compiles in the background while the server indexes.
# Stopping the installer for any reason stops that build's whole process tree
# and removes its captured output.
MENUBAR_BUILD_PID=""
menubar_check_out=""
menubar_check_err=""

kill_process_tree() {
local child
for child in $(pgrep -P "$1" 2>/dev/null || true); do
kill_process_tree "$child"
done
kill "$1" 2>/dev/null || true
}

stop_menubar_build() {
if [[ -n "$MENUBAR_BUILD_PID" ]]; then
kill_process_tree "$MENUBAR_BUILD_PID"
wait "$MENUBAR_BUILD_PID" 2>/dev/null || true
MENUBAR_BUILD_PID=""
fi
rm -f "$menubar_check_out" "$menubar_check_err"
}

# Only an already-installed toolchain may build in the background: on a Mac
# without the Command Line Tools, invoking swiftc opens Apple's install dialog,
# which must not appear unannounced while the installer reports indexing.
# `xcode-select -p` only reports the developer directory and never prompts.
swift_toolchain_installed() {
local developer_dir
developer_dir="$(xcode-select -p 2>/dev/null)" || return 1
[[ -x "$developer_dir/usr/bin/swiftc" \
|| -x "$developer_dir/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc" ]]
}

start_menubar_build() {
menubar_check_out="$(mktemp "${TMPDIR:-/tmp}/token-meter-menubar-check.XXXXXX")"
menubar_check_err="$(mktemp "${TMPDIR:-/tmp}/token-meter-menubar-check.XXXXXX")"
"$INSTALL_ROOT/scripts/run-menubar" --check \
>"$menubar_check_out" 2>"$menubar_check_err" &
MENUBAR_BUILD_PID=$!
}

install_cleanup() {
progress_stop
stop_menubar_build
}

trap install_cleanup EXIT
trap 'install_cleanup; trap - INT; kill -INT $$' INT
trap 'install_cleanup; trap - TERM; kill -TERM $$' TERM

fail() {
progress_stop
Expand Down Expand Up @@ -289,6 +337,11 @@ if (( BACKEND_ONLY )); then
"$INSTALL_ROOT/scripts/install-launch-agent" backend-only
else
"$INSTALL_ROOT/scripts/install-launch-agent" server-only
# The build needs only the staged source and the Swift toolchain, so it runs
# while the server indexes. Its output is held until the build is reported.
if swift_toolchain_installed; then
start_menubar_build
fi
fi

echo "Indexing local agent history (first run can take a few minutes)…"
Expand Down Expand Up @@ -339,21 +392,23 @@ else
curl -fsS --max-time 5 "$MENUBAR_URL" \
| python3 -c 'import json, sys; json.load(sys.stdin)' \
|| fail "the menu bar endpoint did not return valid JSON."
echo "Building the menu bar companion (first build compiles Swift, ~1 min)…"
# Hold the build's own output until the spinner line is cleared, then replay
# it unchanged so compiler messages never share a line with the spinner.
menubar_check_out="$(mktemp "${TMPDIR:-/tmp}/token-meter-menubar-check.XXXXXX")"
menubar_check_err="$(mktemp "${TMPDIR:-/tmp}/token-meter-menubar-check.XXXXXX")"
trap 'progress_stop; rm -f "$menubar_check_out" "$menubar_check_err"' EXIT
if [[ -n "$MENUBAR_BUILD_PID" ]]; then
echo "Finishing the menu bar companion…"
else
echo "Building the menu bar companion (first build compiles Swift, ~1 min)…"
start_menubar_build
fi
# Wait for whatever is left of the build. Its output is replayed only after
# the spinner line is cleared so compiler messages never share a line with
# the spinner.
progress_start "Building the menu bar companion" "Still building…"
menubar_check_status=0
"$INSTALL_ROOT/scripts/run-menubar" --check \
>"$menubar_check_out" 2>"$menubar_check_err" || menubar_check_status=$?
wait "$MENUBAR_BUILD_PID" || menubar_check_status=$?
MENUBAR_BUILD_PID=""
progress_stop
cat "$menubar_check_out"
cat "$menubar_check_err" >&2
rm -f "$menubar_check_out" "$menubar_check_err"
trap progress_stop EXIT
if ((menubar_check_status != 0)); then
fail "the native companion cannot build with the installed Swift toolchain and macOS SDKs."
fi
Expand Down
57 changes: 55 additions & 2 deletions specs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,54 @@ session id and some runtimes reuse one file name per session directory; All
sessions rows use the same key for DOM identity. Each compared trace also
carries an `open_id` that resolves to exactly that trace when possible.

### Cold indexing

A cold build (first install, restart, or many changed traces) is the one
moment every trace is parsed. When the server has at least 16 uncached
sources, `prefetch_session_summaries` fills the summary cache from a
spawn-context process pool before the unchanged sequential loop runs; the
loop then reuses those rows, and anything the pool did not store (too few
sources, one CPU, a worker error, a broken or unstartable pool) is summarized
in-process with identical results. Only `main()` enables the pool, so tests,
MCP, and imports never spawn processes, and only hosts whose
`PlatformCapabilities.parallel_index` is true use it, which today is macOS
only. Linux and Windows keep the sequential cold index until worker processes
are verified on those hosts (Linux keeps its tested `/proc/stat` and
`power_supply` probes for that); both still get the matched-pace speedup below. Adapters that declare
`worker_summaries = False` (OpenCode, which shares one read-only connection)
stay in the parent. Workers watch the parent's sentinel and exit as soon as the
server dies, and they run from the temporary directory so they never pin the
runtime directory a reinstall moves. Workers return raw adapter rows; the parent applies
configured capabilities and stores them under the pre-dispatch revision
signature exactly as `session_summary` does. Adapters may hand workers
content-free warm-start state (`export_worker_state`/`import_worker_state`);
Codex passes its signature-validated trace-head metadata so each worker does
not reread every rollout to resolve lineage.

The pool must not slow the machine. `token_meter/services/indexing.py` caps it
at half the CPUs the process may use (Linux CPU affinity included) (at most six, two on battery or low-power mode) and
re-derives the in-flight limit every second from measured host idle CPU with
additive increase and multiplicative decrease: it halves when less than one
CPU is idle and grows by one only while two are idle. Saturation also raises a
shared signal that moves workers to the platform's yielding class (macOS
Utility QoS, where nice alone barely changes placement) until capacity
returns. Memory is bounded separately, because the CPU signal cannot see
swapping: the pool allows one worker per 4 GiB of physical memory (two when it
is unknown), and the kernel memory-pressure level caps the in-flight limit at
half the pool on `warn` and drops it to one worker on `critical`. Because a
Python worker keeps its peak memory until it exits, pressure also restarts the
pool at that smaller size once in-flight work drains. Workers
otherwise run niced (with utility disk I/O on macOS) and exit when the build
finishes.
`PlatformServices` owns priority, power state, memory pressure, and CPU tick
sampling; any
unavailable signal degrades to the static ceiling rather than an error.

Matched-pace comparisons precompute each turn's workload features once and
skip pairs outside the output gate by a log-output band, with the same float
operations and random draws as the per-pair form, so results and persisted
pair caches are bit-identical.

Filesystem modification time is a revision signal, not automatically user/model
activity. Adapters derive semantic activity from trace events or authoritative
metadata so merely opening a historical session does not promote it into
Expand Down Expand Up @@ -398,7 +446,11 @@ byte-compares every expanded source file afterward.
The public installer dispatches by host:

- macOS stages under `~/Library/Application Support/Token Meter/runtime` and
manages `com.token-meter.server` plus `com.token-meter.menubar` LaunchAgents;
manages `com.token-meter.server` plus `com.token-meter.menubar` LaunchAgents.
The menu bar companion compiles in the background while the server indexes;
the installer then waits for that build, checks it, and registers its
LaunchAgent only after the server is ready. A failed or interrupted install
stops the background build and removes its captured output;
- Linux stages under `${XDG_DATA_HOME:-~/.local/share}/token-meter/runtime` and
manages server/tray `systemd --user` services;
- Windows stages under `%LOCALAPPDATA%\Token Meter\runtime` and manages the
Expand All @@ -419,7 +471,8 @@ free of runtime-name branches.

### Add an operating system

Expected changes are one `PlatformServices` implementation, platform-native
Expected changes are one `PlatformServices` implementation (including the
best-effort background-work hooks used by cold indexing), platform-native
installer/startup/update/uninstall helpers, one native compact client, manifest
contracts, and target-host lifecycle tests. Runtime parsers and domain logic
must remain untouched.
Expand Down
9 changes: 7 additions & 2 deletions specs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,15 @@ native tray, update helper, and uninstall logic platform-native. All installers
must consume `runtime-manifest.txt` and run its parity validator before they
change services. Portable contracts are necessary, but install, automatic
startup, endpoint ownership, tray interaction, parity, and uninstall must also
be proven on the target operating system.
be proven on the target operating system. The background-work hooks
(`lower_background_priority`, `set_background_mode`, `power_constrained`,
`memory_pressure`, `cpu_activity`) are best-effort: return `False` or `None` when the host cannot
answer, never raise. Set `PlatformCapabilities.parallel_index` only after
cold-index workers are proven on the target host (they start, run at reduced
priority, open no windows, and exit with the server).

```bash
python3 -m unittest tests.contracts.test_platform_services tests.contracts.test_runtime_manifest -v
python3 -m unittest tests.contracts.test_platform_services tests.contracts.test_platform_background_work tests.contracts.test_runtime_manifest -v
python3 -m unittest tests.contracts.test_windows_packaging -v
```

Expand Down
163 changes: 163 additions & 0 deletions tests/contracts/test_platform_background_work.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock

from token_meter.platforms import linux, macos
from token_meter.platforms.base import CpuActivity
from token_meter.platforms.registry import platform_services


PLATFORMS = ("darwin", "linux", "win32", "plan9")
BATTERY = "Now drawing from 'Battery Power'\n -InternalBattery-0\t80%; discharging"
MAINS = "Now drawing from 'AC Power'\n -InternalBattery-0\t100%; charged"


class BackgroundWorkContractTests(unittest.TestCase):
def test_every_platform_implements_the_background_work_hooks(self):
for name in PLATFORMS:
with self.subTest(platform=name):
platform = platform_services(name, environment={}, home="/home/example")
for method in ("lower_background_priority", "power_constrained",
"cpu_activity", "set_background_mode", "memory_pressure"):
self.assertTrue(callable(getattr(platform, method, None)), method)

def test_unsupported_platform_reports_unknown_and_changes_nothing(self):
platform = platform_services("plan9", environment={}, home="/home/example")
self.assertFalse(platform.lower_background_priority())
self.assertFalse(platform.set_background_mode(True))
self.assertIsNone(platform.power_constrained())
self.assertIsNone(platform.cpu_activity())
self.assertIsNone(platform.memory_pressure())

def test_posix_priority_is_best_effort(self):
platform = platform_services("linux", environment={}, home="/home/example")
with mock.patch.object(os, "nice") as nice:
self.assertTrue(platform.lower_background_priority())
nice.assert_called_once_with(10)
with mock.patch.object(os, "nice", side_effect=OSError("denied")):
self.assertFalse(platform.lower_background_priority())


class MacOSBackgroundWorkTests(unittest.TestCase):
def setUp(self):
self.platform = platform_services("darwin", environment={}, home="/Users/example")

def test_pmset_power_states(self):
cases = [
((BATTERY, " powermode 0\n"), True),
((MAINS, " powermode 0\n"), False),
((MAINS, " powermode 1\n"), True),
((MAINS, " powermode 2\n"), False),
((MAINS, " lowpowermode 1\n"), True),
(("", " lowpowermode 0\n"), False),
((MAINS, ""), False),
((MAINS, " lowpowermode 1\n powermode 0\n"), True),
(("", ""), None),
(("unexpected", "garbage"), None),
]
for outputs, expected in cases:
with self.subTest(outputs=outputs):
self.assertEqual(macos.parse_pmset_power(*outputs), expected)

def test_pmset_failures_mean_unknown(self):
for error in (OSError("missing"), subprocess.TimeoutExpired("pmset", 2)):
with self.subTest(error=type(error).__name__):
def run(args, error=error):
raise error
self.assertIsNone(self.platform.power_constrained(run=run))
outputs = {("-g", "batt"): BATTERY, ("-g",): ""}
self.assertTrue(self.platform.power_constrained(run=outputs.__getitem__))

def test_kernel_memory_pressure_levels(self):
cases = [(1, "normal"), (2, "warn"), (4, "critical"), (0, None), (3, None), (None, None)]
for level, expected in cases:
with self.subTest(level=level):
self.assertEqual(self.platform.memory_pressure(reader=lambda level=level: level), expected)

def broken():
raise OSError("sysctl unavailable")
self.assertIsNone(self.platform.memory_pressure(reader=broken))

def test_live_memory_pressure_reads_a_known_level_on_macos(self):
if os.uname().sysname != "Darwin":
self.skipTest("macOS kernel sysctl")
self.assertIn(self.platform.memory_pressure(), ("normal", "warn", "critical"))

def test_host_cpu_ticks_include_nice_in_busy(self):
activity = self.platform.cpu_activity(reader=lambda: (10, 5, 80, 3))
self.assertEqual(activity, CpuActivity(busy=18, idle=80))
self.assertIsNone(self.platform.cpu_activity(reader=lambda: None))
self.assertIsNone(self.platform.cpu_activity(reader=lambda: (1, 2)))
self.assertIsNone(self.platform.cpu_activity(reader=lambda: ("a", 1, 2, 3)))

def broken():
raise OSError("no mach")
self.assertIsNone(self.platform.cpu_activity(reader=broken))


class LinuxBackgroundWorkTests(unittest.TestCase):
def setUp(self):
self.platform = platform_services("linux", environment={}, home="/home/example")
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)

def supply(self, name, **fields):
folder = self.root / name
folder.mkdir()
for field, value in fields.items():
(folder / field).write_text(value + "\n")

def test_proc_stat_counts_nice_as_busy_and_iowait_as_idle(self):
text = "cpu 100 20 30 400 50 6 7 8 0 0\ncpu0 1 2 3 4 5 6 7 8\n"
self.assertEqual(linux.parse_proc_stat(text), CpuActivity(busy=171, idle=450))
self.assertEqual(linux.parse_proc_stat("cpu 1 2 3 4\n"), CpuActivity(busy=6, idle=4))
self.assertIsNone(linux.parse_proc_stat("cpu 1 2\n"))
self.assertIsNone(linux.parse_proc_stat("cpu a b c d\n"))
self.assertIsNone(linux.parse_proc_stat(""))
self.assertIsNone(self.platform.cpu_activity(path=str(self.root / "missing")))

def test_power_supply_states(self):
self.assertIsNone(self.platform.power_constrained(root=str(self.root / "missing")))
self.assertIsNone(self.platform.power_constrained(root=str(self.root)))
self.supply("BAT0", type="Battery", status="Discharging")
self.assertTrue(self.platform.power_constrained(root=str(self.root)))
self.supply("AC", type="Mains", online="1")
self.assertFalse(self.platform.power_constrained(root=str(self.root)))

def test_peripheral_batteries_and_idle_usb_ports_do_not_imply_battery(self):
self.supply("hidpp_battery_0", type="Battery", scope="Device", status="Discharging")
self.supply("ucsi-source-psy-USBC000:001", type="USB", online="0")
self.assertIsNone(self.platform.power_constrained(root=str(self.root)))

def test_offline_adapter_means_battery(self):
self.supply("AC", type="Mains", online="0")
self.supply("BAT0", type="Battery", status="Not charging")
self.assertTrue(self.platform.power_constrained(root=str(self.root)))


class WindowsBackgroundWorkTests(unittest.TestCase):
def test_windows_keeps_cold_indexing_sequential_and_probes_nothing(self):
platform = platform_services("win32", environment={}, home=r"C:\Users\example")
self.assertFalse(platform.capabilities().parallel_index)
self.assertFalse(platform.lower_background_priority())
self.assertFalse(platform.set_background_mode(True))
self.assertIsNone(platform.cpu_activity())
self.assertIsNone(platform.power_constrained())
self.assertIsNone(platform.memory_pressure())


class ParallelIndexCapabilityTests(unittest.TestCase):
def test_only_verified_hosts_enable_parallel_indexing(self):
expected = {"darwin": True, "linux": False, "win32": False, "plan9": False}
for name, enabled in expected.items():
with self.subTest(platform=name):
platform = platform_services(name, environment={}, home="/home/example")
self.assertIs(platform.capabilities().parallel_index, enabled)


if __name__ == "__main__":
unittest.main()
Loading
Loading