Commit Graph

13 Commits

Author SHA1 Message Date
filip131311 5d76ed4d66 feat: tag artifacts with a semantic kind at registration (#564)
Fresh, from-current-main take on the artifact-kind idea from #398 /
#466. Knowing what an artifact *represents* (not just its MIME type) is
what a UI needs to group and name artifacts (e.g. in EAS); this PR
delivers exactly that — the tagged handle — without the
declared-output-map machinery.

## Summary

- Adds a closed `ArtifactKind` union to the artifact wire contract;
`kind` is carried on `ArtifactHandle`, `ArtifactEntry`, and the
`/artifacts` inventory items.
- `ArtifactStore.register` now takes a single `{ hostPath, kind, ... }`
options object, so the compiler forces every producer — current and
future — to declare what it is emitting.
- Tags **all** producers on main today, including the ones #398/#466
predate: screenshot (all four capture paths), screenshot-diff, **flow
snapshot baselines/diffs** (`screenshot` / `screenshot-diff-context`),
**screen recordings** (new `screen-recording` kind), and the
native/React profiler exports and reports.
- Types the native-profiler export keys at their source (`IosExportKey`
from the `EXPORTS` table, `AndroidExportKey`) and maps them through a
kind table that is **total over both unions** — a newly added export key
fails to compile until classified, replacing the previous stringly-keyed
loop (and the `?? "native-profile-trace"` silent-default this design
would otherwise need).
- Client-side (`@argent/tools-client`), the mirrored handle type keeps
`kind` **optional and widened** (`ArtifactKind | (string & {})`): an
older, already-running tool-server sends no kind, and a newer server may
send kinds this client build predates. Consumers treat it as advisory
metadata; unknown kinds are opaque, never an error.

## Differences from #398 / #466 (deliberate)

- No `@argent/artifacts` package, no third `ToolDefinition` generic, no
per-tool output-name registrar, no runtime throw for “undeclared output”
— the kind is plain data at the registration call site, checked at
compile time where it can be, and a wrong label can never fail a tool at
runtime. If a pre-invocation `artifacts` declaration in tool-listing
metadata is ever needed (an actual EAS consumer), it can be layered on
later without changing this wire contract.
- The two `ArtifactKind` definitions (registry / tools-client) stay
deliberately separate — same as the existing `ARTIFACT_MARKER`
must-match convention — because the client-side type is intentionally
*looser* (optional + open) than the server-side one for version skew.
- Extending the union stays backward-compatible on the wire.

## 2026-08-24 update

Merged current main (0.22.0) into the branch. The flow-visual rewrite
that landed since (cropOn crops, per-app snapshot-key dedupe, diff
scratch-dir hygiene) is preserved verbatim; its five registration sites
now carry kinds (`screenshot` for baseline/current,
`screenshot-diff-context` for the annotated diff — the cropped `current`
included). No producer added since the PR was opened needed a new kind:
`paste`, `shake`, and the other new tools emit no file artifacts, and
every register call site in the tree now goes through the typed options
object.

No docs update is needed: the docs site (added since this PR) does not
document the artifact handle wire contract; `kind` is advisory wire
metadata with no user-facing surface.

## Validation

At the merge with main (0.22.0), 2026-08-24:

- `npm run build` (full `tsc --build`) — clean
- `npm run typecheck:tests` in registry, tools-client, tool-server, mcp,
cli — clean
- Full suites green: registry 90, tools-client 191, tool-server 4495 (+1
skipped), mcp 80, cli 472
- `prettier --check .`, `eslint . --max-warnings 0`, and the knip gate —
all pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-24 12:09:51 +02:00
Ignacy Łątka da61d4f2eb test(tool-server): pin the ARGENT_* suite guard and correct what it documents (#859)
Post-merge review of #707. Three findings, all from that change's own
surface; no behaviour outside the test suite moves.

## 1. Nothing pins the guard

`test/setup/clear-argent-env.ts` strips the developer's `ARGENT_*`
overrides so unit tests assert argent's shipped defaults. On a machine
with nothing exported its loop body never runs, and CI is exactly such a
machine, so no suite failure can report the guard being weakened.
Measured at `9dc7c7cfc`, each mutation applied alone to the shipped
file:

| mutation | full tool-server suite |
|---|---|
| body replaced with `export {}` | 350 files / 4300 passed, **exit 0** |
| `startsWith("ARGENT_")` -> `startsWith("ARGENT")` | 350 files / 4300
passed, **exit 0** |
| `delete process.env[name]` -> `process.env[name] = ""` |
`bind-failure-telemetry.test.ts` 3 passed, **exit 0** |
| entry removed from `setupFiles` | suite green |

All four survive every gate: `vitest`, `eslint`, `prettier`, `tsc`,
`typecheck:tests`, `knip`.

`test/clear-argent-env.test.ts` re-imports the module against sentinels
planted at call time, so the loop runs on demand rather than only when
the developer happens to have an override exported. Each mutation now
kills exactly one test, and the mapping is distinct:

```
body removed        -> removes an ARGENT_ key outright instead of blanking it
prefix underscore   -> keeps a name that shares the prefix without the underscore
delete -> blank     -> removes an ARGENT_ key outright instead of blanking it
setupFiles entry    -> is registered as a setup file, so it runs before any test module
```

`ARGENTINA_REGION` is the look-alike: it pins that the underscore is
load-bearing. The `in` check rather than a truthiness check is
deliberate -- `= ""` reads as cleared to `if (process.env.X)` while
still reaching a spawned child as a set key.

## 2. The header named the wrong file, and argued from the smallest
cases

> `ARGENT_PORT` / `ARGENT_HOST` retarget the bind that the
startup-telemetry tests inspect.

`startup-telemetry.test.ts` exists in that directory, mocks
`createHttpApp`, and asserts nothing about host or port.
`bind-failure-telemetry.test.ts` is the file that inspects the bind, and
it is the one #707's own table named. A reader following the comment
opens the sibling that literally bears that name and finds no bind
assertion.

The example list also covered only the four variables worth 9 failures,
while the widest one was absent. Guard removed, one variable, full
suite:

```
ARGENT_AUTH_TOKEN=devtoken -> Test Files 10 failed | 340 passed (350)
                              Tests 80 failed | 4220 passed | 1 skipped (4301)
```

It puts every HTTP route behind a bearer check (`src/http.ts:59`), so
ten files fail on status code alone. That is the strongest argument for
scrubbing the whole prefix instead of a hand-maintained list, and the
paragraph defending exactly that choice did not have it.

## 3. A comment #707 silently falsified

`malloc-stack-logging.test.ts:89-95` promised to "restore the ambient
value afterwards", capturing `process.env.ARGENT_IOS_CAPTURE` at module
scope. The guard clears that variable before the module is imported, so
the capture is always `undefined` and the `else` arm of the `afterEach`
restore is unreachable. Clearing in `afterEach` is what the file
actually needs and all it now claims.

`export {}` is appended to the setup file so `tsconfig.test.json` --
which includes `test/**/*` -- treats it as a module rather than a global
script; without it `typecheck:tests` fails with TS2306.

## Verification

Run at `d0cb84de5`, rebased on `9dc7c7cfc`.

- Full `packages/tool-server`: **351 files, 4306 passed, 1 skipped, 0
failed** on a clean environment, and identical with
`ARGENT_EMULATOR_GPU_MODE=host ARGENT_EMULATOR_NO_WINDOW=1
ARGENT_SIMULATOR_NO_WINDOW=1 ARGENT_PORT=9999 ARGENT_HOST=0.0.0.0
ARGENT_AUTH_TOKEN=devtoken ARGENT_IOS_CAPTURE=all-processes` exported.
- Mutation matrix above re-run against the new test: 4/4 killed,
intended test each time, control green.
- `prettier --check .` 0, `eslint . --max-warnings 0` 0, `tsc --build`
0, `typecheck:tests` 0, `knip` 0 (206 issues against the 215 ceiling,
unchanged -- the new file is not flagged).

## Checked and left alone

- The four `it.each(PLATFORMS)` retitles from #707 render correctly (`on
linux ... -gpu swiftshader`); all four sites were fixed and a sweep of
all 77 `.each(` sites in the repo found no other transposition.
- The guard is genuinely unnecessary in the other ten packages: the
union of all 53 `ARGENT_*` names read anywhere in `packages/*/src`,
exported in two opposing polarities, leaves every other suite green.
`argent-tools-client` already has its own copy from #714.
- No CI workflow exports an `ARGENT_*` variable into a vitest process,
so nothing in CI is destroyed by the scrub. `wayland-e2e.yml:46` sets
`ARGENT_EMULATOR_NO_WINDOW` at job level but runs no vitest.
- The other vestigial ambient save/restore pairs (`window-shake`,
`http-auth`, `http-upload`, `http-flow-path-boundary`,
`update-argent-tool`, `boot-device`, `boot-device-hotboot`) carry no
false prose and are harmless defence-in-depth, so they stay.
2026-08-21 12:01:10 +02:00
Ignacy Łątka 5d8fc10799 test: stop tests binding machine-wide resources they do not own (#714)
## Why

Three flaky or failing tests were diagnosed separately this week, and
all three turned out to be one defect wearing different clothes: **a
test binding a machine-wide resource it does not own**, so a second
concurrent run — another checkout, a CI matrix cell, a developer's own
app — perturbs it. Those three are fixed in #707, #709 and #711.

Each of those was found by chasing a symptom, and each audit covered
only its own resource type. This is the systematic sweep: all **448 test
files across 13 packages**, plus the 4 `node --test` scripts. (The sweep
itself ran over the 400 files that existed when it started; merging
`main` brought the count to 448, and every category below was re-run
over the full set — 25 files assign `env.HOME` and none omit
`USERPROFILE`, one test file calls `pkill`/`pgrep`
(`vega-cli-timeout.test.ts`, scoped out to #711 below), and no
`listen()` names a fixed port.)

## Findings

| Category | Files checked | Real findings |
|---|---|---|
| Network endpoints (bind / probe / unix socket) | 448 | 4 |
| Filesystem — shared temp namespace (`os.tmpdir()` and literal `/tmp`)
| 448 | 8 |
| Filesystem — real `$HOME` / `~/.argent` | 448 | 4 (28 files) |
| Ambient environment | 448 + all `src` readers | 12 |
| Device / emulator / SDK state | 448 | 2 |
| Process table (`pgrep` / `pkill` / fixed-pattern sweeps) | 448 | **0**
|
| In-process globals leaking across files | 13 packages × 13 modes |
**0** |

Every finding was reproduced with a concrete instrument before being
touched, and the instrument re-run after. Representative:

- **`stop-tools.test.ts`** ran the real `stop-metro` against port 59999,
which `lsof`s and **SIGTERMs whatever it finds**. With a squatter there:
`16 passed` → `1 failed`, and the squatter logged `received SIGTERM #1`.
Now port 1.
- **`bind-failure-telemetry` / `lens-relay-telemetry`** were gated on
the real `~/.argent/flags.json`. Enabling the documented
`tool-server-event-log` flag: `4 passed` → **`4 failed`**, and records
were appended to the developer's real event log.
- **LogFileWriter (12 files)** — its constructor `mkdir -p`s
`os.homedir()/.argent/tmp`. Instrument: run each of the 16 test files
that reference a writer, or a blueprint that builds one, under a scratch
`HOME`/`USERPROFILE`, then test `$HOME/.argent/tmp` for existence. 4
created it; the 12 that actually construct a writer now all call
`scopeTempHome`, and none creates it.
- **`screen-recording.test.ts`** — the device ids are fixtures and
`vi.useFakeTimers()` seeds its clock from the real time, so only the
millisecond a run starts in separates two runs' `.mp4` paths, and each
test deletes the path it derived. Self-concurrent: **2 of 3 rounds
failed** → 0 of 4. Scoping `TMPDIR` removes the window rather than
narrowing it.
- **`DO_NOT_TRACK=1`** → telemetry `282 passed` → **`13 failed`**.
**`npm_config_user_agent=pnpm|yarn|bun`** → installer `535 passed` →
4/5/3 failed. **19 tests required a real `adb`** — PATH shadow + empty
HOME: `30 passed` → **`19 failed`**.
- A fixed `/tmp/argent-nd-<udid8>.sock` **destroyed a concurrent
holder's live socket** (proven by inode) in **3** files that run the
native-devtools factory. Two of them also fail outright:
`native-devtools-app-state` lost **3 of 3** rounds of two concurrent
runs on `expected 'unregistered' to be 'connected'`. All three now
derive the UDID from `process.pid`.
- `flow-visual`, `react-profiler/dump`, `file-inputs`, `http-upload` —
shared `/tmp` paths and prefix scans, each reproduced with a squatter.
In `http-upload` the collision surfaced *as a timeout*, inside
`vi.waitFor`. (`flow-chromium-boot` was fixed on this branch too, but
main's #585 rewrote that file afterwards and the merge kept main's copy,
so it is byte-identical to `origin/main` here and carries no fix.)

## Clean, with the evidence

- **Process table** — an `LD_PRELOAD` exec interposer over a full run
(507 execs) plus all 12 other suites. Excluding
`vega-cli-timeout.test.ts`, which is #711's and does sweep the table
(`pkill -f '^sleep 6910[0-9]$'` at line 134): no
`pgrep`/`pkill`/`killall`/fixed-pattern sweep anywhere, and **no test
signals a pid it did not spawn**.
- **Real device state** — **zero** exec attempts of real `adb` /
`simctl` / `emulator` / `avdmanager` / `vega`; no test asserts a device
list. That list was too narrow: `workspace-reader-integration` spawned
the real `yarn` / `pnpm` / `bun` / `pod` / `eas` / `expo`, which is the
second finding in this row.
- **Cross-file in-process leakage** — structurally impossible: vitest
4.1.9 resolves `pool: "forks"` / `isolate: true`, confirmed by a scratch
experiment (distinct pids, no env leak, module `Map` = 1,1,1). 120
default-mode runs across 12 packages produced 1 failure.
- **`scripts/`** — 4 files, 86 tests: `mkdtemp` throughout, no ports, no
sweeps, writes nothing.

## Verification

Each fix was mutated back and killed by a **named** test under its
instrument — e.g. `× reads a plain absolute path`, `× returns null when
only the extensionless binary exists`, `× pins both names os.homedir()
consults, at the same directory`. Restores verified with `git diff` /
`git status`, never a substring grep.

**Pinned by an instrument only:** the socket fix for
`ios-only-blueprint-gate`, the `~/.argent/tmp` and
`~/.argent/flags.json` fixes, the `/tmp` fixture cleanup, the
`stop-metro` port change (the test asserts `stopped: false` / `pids:
[]`, which hold for either port whenever nothing is listening) and the
`workspace-reader` `PATH` pin (only `node` is asserted; the other seven
probes are not) are not pinned by any suite assertion — reverting them
still passes, and only the external squatter / scratch-`HOME`
instruments catch them. They are pollution fixes. The
`native-devtools-app-state` socket fix is the exception in the other
direction: reverting it turns two concurrent runs red.

Gates, unpiped and read by exit code: `eslint .` 0, `prettier --check .`
0, `tsc --build` 0, `typecheck:tests` 0.
Suite, run as six shards: tool-server **4287 passed, 1 skipped, 0
failed**; argent-cli 462; telemetry 306; argent-tools-client 191;
configuration-core 122; argent-mcp 80; argent-installer 555. All other
packages and `test:scripts` green, identical to baseline.

`lens-tools-platform-gate.test.ts` times out at the 5s default under
full-suite load and passes at `--testTimeout=30000` (24.7s, almost all
of it transform). It predates this branch and is untouched by it.

## Scope

This branch is **test-only** — no `src/` file is touched. Both
production defects the sweep turned up are separated out, so each can be
reviewed as the user-facing fix it is: `ps` without `-ww` orphaning live
tool-servers is #713, and #852 is `ensureToolsServer` respawning on
every call when `ARGENT_HOST` is exported — `buildToolsServerEnv` passes
the inherited value to the child while the state records the literal
`127.0.0.1` the health check then probes. The second surfaced here as
eight stranded `fake-tool-server` processes, and pinning the environment
in this branch removes that signal, which is why it is filed rather than
merely worked around. A third, outside this branch entirely, is #853:
ten `tool-server` test servers omit the host on `listen(0)`, binding
every interface where twenty-two sibling sites pass `127.0.0.1`. It is
hardening, not a live exposure — `createHttpApp`'s Host allow-list
answers a LAN-addressed request with 403, and only a raw client forging
`Host: 127.0.0.1` reaches the stub registry behind it, inside the ~100
ms one `it()` holds the socket. It is also not a collision risk:
ephemeral ports are kernel-assigned and never reissued while in use,
which is why the fixed-port audit above is unaffected. All ten predate
this branch.

Left untouched on purpose: `boot-electron-spawn-error.test.ts`,
`chromium-discovery.test.ts` and `vega-cli-timeout.test.ts` — owned by
the still-open #709 and #711, and all three still carrying their defects
on this base. (#707 has since merged into this base, so
`boot-device-hotboot.test.ts` and `vitest.config.ts` carry its fix
rather than a defect; `test/setup/clear-argent-env.ts` is its work, and
this branch relies on it.)

**Not fixed, deliberately** — an adjacent class (test-*order*
dependence, not machine-global resources), reproducible if wanted:
`link-config.test.ts` (deterministic: `-t "returns null when required
field"` → 4 failed), `network/network-integration.test.ts` and
`android-perfetto/dispatch.test.ts` (fail under `--sequence.shuffle`
seeds).

**Also observed, not addressed:** one green tool-server run leaves
**56** entries behind in a scratch `TMPDIR` — **30** in the
`argent-screenshot-diff*` family, 4 `argent-events-*`, 3
`argent-flow-crop-*`, 3 `argent-tar-upload-*`, 2 `argent-flow-diff-*`, 2
`argent-file-input-*`, and 12 assorted singletons (`ast-index-*`,
`component-source-*`, `argent-profiler-cwd`, `argent-chromium-media`,
…). Measured per run against a scoped temp dir, so it ranks the
contributors rather than counting one machine's accumulated history —
screenshot-diff, not flow-crop, is the dominant one. A separate leak.
That instrument is blind to a *literal* path, which is how it missed the
two `/tmp/native-profiler-*-report.md` writers the fourth pass found.
Re-run across all 13 packages, `argent-mcp` is the only one outside
`tool-server` that leaves anything — two entries per run, filed as #854.


## Second pass

A review of this branch re-ran every category above with its own
instrument rather than reading the diff, and found the sweep had pinned
four resources in one file while leaving a sibling that does the same
thing live. Fixed in `4289e3d8f` and `8164955b4`; the table and bullets
above are the corrected totals.

- **`tool-server-event-log`** was mocked off in 2 of the 4 files that
call the real `start()`. With the flag on, `startup-telemetry` and
`tool-fail-invalid-params-emission` failed 3 tests in
`attachRegistryEventLogger` and truncated the real
`~/.argent/tool-server-events.jsonl` to 0 bytes first.
`ARGENT_EVENT_LOG` cannot redirect that:
`test/setup/clear-argent-env.ts` strips every `ARGENT_*` var before the
module graph loads.
- **`scopeTempHome`** reached 8 files; 4 more built a real
`LogFileWriter` through the JS-runtime-debugger blueprint.
- **`os.homedir()` on Windows** reads `USERPROFILE`, not `HOME`.
`android-binary-windows` and `adb-resolve-avd-path` pinned only `HOME`,
and both run on a real windows-latest host. Proven by running the
resolver under a `node:os` mock that reproduces the Windows lookup: with
`HOME` alone the stock Studio SDK under the profile resolves; with both
pinned it misses. `update-checker` had the same gap.
- **`getConsentState`** honours a falsy `ARGENT_TELEMETRY` at the same
precedence as `DO_NOT_TRACK`, ahead of the config override the suite
writes. Only `DO_NOT_TRACK` was pinned: `ARGENT_TELEMETRY=0` gave `17
failed | 6 passed`.
- **`workspace-reader-integration`** let the version probe spawn the
real package managers off `PATH`; their shims self-install, so the file
pulled **~44 MB** into `~/.cache/node/corepack` and
`~/Library/Caches/eas-cli`. `PATH` is now a directory holding only
`node`, the one version the test asserts — 44 MB → 4 KB.
- **`argent-mcp`** snapshotted `ARGENT_AUTO_SCREENSHOT_DELAY_MS` without
clearing it, and that package has no `ARGENT_*`-stripping setup file, so
3 tests asserted the developer's exported value.

Smaller items in the same commits: the `/tmp` socket fixtures now remove
the directories they create per run; `http-upload`'s pre-scan snapshot
is gone, since scoping `TMPDIR` to a fresh directory made it permanently
empty and its filter inert, as its two siblings already did; and two
`detectProjectPackageManager` assertions that enumerated the entire
return union are pinned to a single value.


The confirmation pass also caught a defect the first round of fixes
introduced: pinning `HOME`/`USERPROFILE` to the fixture root in
`android-binary-windows` let `<home>/Android/Sdk` shadow the
`%LOCALAPPDATA%` root the two positive tests are named after, so
deleting `roots.push(join(localAppData, …))` from the resolver left all
three green. Home has its own directory in `6aee3ee7a`, and that
mutation now fails both tests.


A third pass, running the nearest-twin move repo-wide, found the
`os.homedir()`-on-Windows gap in one more package: eight
`argent-tools-client` test files redirect only `HOME`, and four of them
recursively delete `STATE_DIR` — two before each test, two after — which
on Windows is the developer's real `%USERPROFILE%\.argent`. Fixed in
`139447d4a`, together with the same gap in `avd-snapshot`. Nothing runs
that package on Windows CI today, so no current run fires it.

## Fourth pass

A fourth pass audited the three fix commits and re-ran the sweep with
two instruments the earlier ones did not have. Both holes were in the
*measurement*, not the diff — which is why three passes missed what they
found. Fixed in `a67c43f8d`.

- **`os.tmpdir()` does not read the same variable on every platform.**
POSIX takes `TMPDIR`, `TMP`, `TEMP` in that order; Windows takes `TEMP`,
then `TMP`, and never looks at `TMPDIR`. The five pins this branch added
set `TMPDIR` alone, so on Windows production still materialized into the
machine-wide `%TEMP%` while the test scanned a scratch directory nothing
ever wrote to — and `file-inputs`, `http-upload` and `flow-visual` each
end in a leak assertion over that listing, which then passes whatever
the code does. Against `origin/main`, which diffed a before/after
listing of the real tmpdir, that is a regression rather than a wash.
`redirectTmpdir` (`test/helpers/tmpdir-env.ts`) sets all three and
returns the restore; the **7** files that scope a tmpdir use it,
including the two `android-perfetto` ones that predate this branch.
`test/tmpdir-env.test.ts` pins both lookup chains — reverting the helper
to `TMPDIR` alone fails two of its three tests. All three leak
assertions were re-confirmed live on POSIX by mutating the production
cleanup each one covers.
- **A hardcoded absolute path is invisible to a `TMPDIR`-scoped
instrument.** `session-correctness` and
`ios-instruments/analyze-freshness` passed a literal
`/tmp/native-profiler-{x,ios}.trace` as the session's trace file, and
the renderer writes its report beside it (`deriveReportPath` =
`dirname(traceFile)/<base>-report.md`). Every run left
`native-profiler-x-report.md` and `native-profiler-ios-report.md` in the
shared `/tmp`; nothing removed them, and two concurrent runs write the
same names. Reproduced with `TMPDIR` pointed at a scratch dir — the
files still landed in `/tmp`, which is exactly why the `os.tmpdir()` row
and the leftover census below both read clean here. Both tests now own
the directory.
- **A prefix-scanned env var is invisible to a grep for variable
names.** `secretSources` enumerates `process.env` by the
`ARGENT_SECRET_` prefix and ranks it *above* every file the test writes,
so `secrets-command.test.ts` — which already pins `HOME`, `USERPROFILE`
and cwd — still read the developer's shell. `ARGENT_SECRET_FOO=bar`,
argent's own documented way to supply a secret: `6 passed` → **`3 failed
| 3 passed`**, deterministic. `argent-cli` has no `ARGENT_*`-stripping
setup file, so the prefix is cleared per test. Its sibling
`configuration-core/tests/secrets.test.ts` was already immune — it
threads an explicit `env: NO_ENV` into every call.
- **`android-binary` and `vega-cli-fallback`** were the last two files
redirecting `HOME` without `USERPROFILE` — the former the direct twin of
`android-binary-windows`, in the same directory, driving the same
resolver. Under a `node:os` mock reproducing the Windows lookup with an
SDK planted in the profile, it loses **5 of 11** tests. No test file in
the repo redirects `HOME` alone any more. (`6fbb66ce1` later skipped
both of this file's describes on win32 — its fixtures are extensionless
and the win32 resolver only takes `.exe` — so its own `USERPROFILE` pin
is now symmetry with the sibling files that do run there, not a live
guard.)

Two smaller items in the same commit. `native-devtools-socket-bind`'s
ENOENT fixture still rooted at `os.tmpdir()` while the comment three
screens above explains why the others do not: with a 142-character
ambient `$TMPDIR` that path exceeds `sun_path` and `listen` rejects it
for *that* reason, which the assertions cannot tell from the
missing-directory one they were written for. And
`<home>\AppData\Local\Android\Sdk` — the resolver's fallback for a
GUI-spawned server that never inherited `%LOCALAPPDATA%` — could be
deleted with the whole suite green; it now has a test, and deleting
either win32 root fails exactly the tests named for it.

## Fifth pass

A fifth pass took the platform-branch shape the fourth pass exposed and
ran it as its own move: *for every function that branches on
`process.platform`, does the test's guard cover every arm, or only the
one the author's machine takes?* Fixed in `e08b466b6`.

- **`init-stale-config.test.ts` deletes the developer's real VS Code MCP
config on Windows.** The file mocks `os.homedir()` and says it does so
"so hidden-scope probes (`~/.claude.json`, VS Code user profile) never
touch the real home directory". `vscodeUserDirs()` branches `darwin →
homedir()/Library/Application Support`, `win32 → %APPDATA%`, `else →
homedir()/.config` — the win32 arm never calls `homedir()`, so the mock
is inert there, and the test's own `userMcpJsonPath()` mirrors the same
branch. Both user-profile tests then write a one-entry fixture over
`%APPDATA%\Code\User\mcp.json` with a whole-file `writeFileSync`, and
assert that `remove()` deleted it. Reproduced by forcing
`process.platform` to `win32` with a two-server `mcp.json` planted at a
probe `%APPDATA%`: **`3 passed`, and the file and its directory were
gone.** With `APPDATA` pinned, the whole file passes under the same
instrument — 31 tests — and the planted config is byte-identical.
- **`argent-tools-client` hands its ambient `ARGENT_*` to a real spawned
child.** The launcher spawns an actual tool-server, and
`buildToolsServerEnv` overrides only the keys it is given — everything
else rides in on `...baseEnv`. `ARGENT_HOST` is the one that bites: the
fixture binds the host it names while the launcher records and
health-checks `127.0.0.1`, so reuse misses and a second server is
spawned. With six overrides exported: **`10 failed | 181 passed`, and
eight stray `fake-tool-server` processes still holding TCP listen
sockets after the run.** With the prefix stripped: `191 passed`, none
surviving. The package now carries the same setup file `tool-server`
has, for the same reason — and it is the second of the two packages
whose missing `ARGENT_*` strip this review found.

## Later passes

A sixth pass audited the fix commits themselves and bounded the SDK
probe (`6fbb66ce1`); a seventh ran the moves against the diff one more
time and turned up the two below, plus the `listen(0)` defect filed as
#853.

- **The `PATH` pin made `workspace-reader-integration` POSIX-only
without the guard its siblings got.** The shim it puts on `PATH` is an
extensionless `node`, and `symlink()` to a file needs an elevated
process or Developer Mode on Windows, so `beforeAll` throws `EPERM`
there before any assertion runs; a copy in place of the symlink would
not rescue it either, since a bare command name resolves through
`PATHEXT`. This sweep already skips `android-binary` and
`native-devtools-socket-bind` on win32 for exactly this class, and the
Windows job runs a curated list of seven `tool-server` files that
excludes this one, so nothing was catching it. Fixed in `095d75bd8` —
verified POSIX `2 passed`, and under a forced win32 platform `2 skipped`
with the top-level `beforeAll` never entered.
- **A rationale that rested on a case the file excludes.**
`android-binary`'s header justified pinning `USERPROFILE` by what
`os.homedir()` reads on Windows, two sentences before noting that both
describes skip on win32. Fixed in `f37b3605e`; the variable stays in the
snapshot list, the justification no longer claims a platform this file
never reaches.

A seventh pass also re-ran the sweep against a hostile machine: every
feature flag on in a scratch `~/.argent/flags.json`, no `adb` on `PATH`,
and a `TMPDIR` long enough to overrun the 104-byte `sun_path` limit. It
found one resource this branch had not closed.

- **A red `launcher-version-gate` run strands a real tool-server.**
`ensureToolsServer` spawns a fixture server, but its pid only reached
the reaper after three or four assertions on the state it returned; any
of those throwing left the pid unrecorded, and the same `afterEach` then
deleted `STATE_DIR`, the only other record of what to kill. **Eight such
processes were alive on the development machine**, spread from 14:33 to
18:23, every one from an `argent-version-gate-test-*` fixture and none
from any other family — each still holding a TCP listen socket with its
fixture directory long gone. Forcing one assertion to fail took the
`tool-server.cjs start` count from 10 to 11. The nearest twin,
`launcher-duplicate-spawn`, guards this twice: it records the pid
straight after the null check and sets `FAKE_TTL_MS` so an escaped
server self-exits. Fixed in `26c2c60ec` with both, the TTL also covering
the window between the spawn and the state read where no pid exists yet
— the same forced failure now leaves **zero** strays.

An eighth pass audited the seventh's own fixes, and found the
version-gate fix had been applied to one file when the defect was a
property of four.

- **Three more files strand a real tool-server on a red run.**
`launcher-spawn` and `launcher-exit` set `FAKE_TTL_MS` nowhere;
`launcher-duplicate-spawn` — the file `26c2c60ec` cites as the model —
armed it inside one test's `try/finally`, so eight of its nine spawns
ran without it and the `finally` disarmed it for the remainder of that
test. Reproduced per file by forcing a failure before the pid is
recorded: a detached node process survives, reparented to init, holding
a listening socket, with its `TEST_HOME` removed and its state record
already cleared. Fixed in `3bb69ded6` by moving the net to a file-wide
`beforeEach` in all three. Verified end to end — the child carries
`FAKE_TTL_MS=60000`, and a stranded server spawned at 21:28:36 exited by
itself at 21:29:36 with nothing signalling it; three consecutive green
runs stay at 191 passed.
- **A 29th file writes the real `~/.argent`.**
`boot-electron-spawn-error`'s success-path test completes a boot, and a
completed boot calls `trackChromiumPort`, persisting to
`os.homedir()/.argent/chromium-cdp-ports.json`. The real file on the
development machine had accumulated **106 dead ephemeral ports**. Its
two siblings in the same directory already mock `chromium-discovery` for
exactly this reason; this was the third, with no mock. Fixed in
`3306b6f40`.
- **`scopeTempHome` was itself unpinned.** Deleting its `USERPROFILE`
line — the Windows half, the same property the sibling helper's test
pins — left all twelve consumers green at 48 passed. `3306b6f40` adds
the missing test; it kills that mutation plus dropping the `HOME` pin
and replacing the per-test `mkdtemp` with one shared directory.

Two findings from that pass were refuted on verification and are
recorded rather than acted on: the tool-server tmpdir leftovers
(mechanism real, but they are the separate leak this body already scopes
out) and a claim that `26c2c60ec`'s message overstated its twin.

Four comments were also corrected against their code: `deriveReportPath`
joins `<base>-report.md` *inside* the trace's directory rather than
appending to the directory's name, which is what makes the
`rm(traceDir)` added in `a67c43f8d` cover it; two `android-perfetto`
comments still named `TMPDIR` as the mechanism after the switch to
`redirectTmpdir`; and `android-binary`'s header claimed the home pin
lets its two positive tests resolve "on either platform", though its
fixtures are extensionless and the win32 resolver only accepts `.exe`.
2026-08-19 23:06:48 +02:00
Hubert Gancarczyk afd722924f feat(flow): support concurrent flow recordings (#574)
## Problem

Only one flow could be recorded at a time, anywhere on the machine.
Recording state was three module globals in `flow-utils.ts` — harmless
in a per-client process, but the tool-server is a **host-wide singleton
per install bundle**. Every MCP client, every subagent sharing a
parent's connection, every `argent` CLI call and every project on the
machine attaches to the same process, the same `Registry`, and those
same three globals. HTTP requests are served concurrently with no
serialization.

Three failures followed, all reproduced against the pre-change code:

1. **A second recording clobbered the first.** `startRecordingSession`
just overwrote, so the first agent's next `flow-add-step` silently
appended into the *other* agent's flow file — in a different project.
2. **Any agent could finish any agent's recording.**
`flow-finish-recording`'s schema was literally `z.object({})`.
3. **Replay stomped recording.** `resolveFlowFilePath` called
`setActiveProjectRoot`, so a `flow-execute` in project B rebound the
global root mid-recording of project A. Already worked around, locally
only, in `flow-add-step`.

## Approach

Recording state is now a `Map` keyed by the resolved flow file path,
`<project_root>/.argent/flows/<name>.yaml` — the identity of the
artifact being built, so "same key" means "same output file" and two
sessions on one key are a genuine collision rather than an accident of
scoping. `flow-add-step`, `flow-add-echo` and `flow-finish-recording`
each take `name` + `project_root`, so every call is self-contained;
required rather than optional-with-fallback, since a fallback would
succeed while an agent is alone and fail only once a second recording
exists.

`resolveFlowFilePath` is pure, which fixes defect 3 and retires
`FLOW_PROJECT_ROOT_REQUIRED` (the state it guarded can no longer exist).

Rejected alternatives — keying by device udid (flows are deliberately
device-portable, and `flow-add-echo`/`flow-finish-recording` have no
device at all), an opaque `recordingId` (an agent that loses the token
cannot recover), and per-caller identity from the transport
(`ToolContext` carries no caller identity, and subagents share their
parent's MCP connection, so it would fail the exact scenario being
fixed).

### Locking, and why readers needed more than a lock

Every mutation of one flow file runs under a lock keyed by that path —
appends, `flow-start-recording`'s truncate-and-register, and
`flow-finish-recording`'s read-summarize-clear. Keyed by file rather
than owned by the session, because a restart *replaces* the session: a
session-owned lock cannot exclude the operation that supersedes it. Per
file, not global, so two recordings never queue behind each other. An
append whose session was finished, restarted or evicted meanwhile fails
with `FLOW_NO_ACTIVE_RECORDING` rather than writing into a different
take.

The lock serializes writers only, and no reader of a flow YAML can join
it — `flow-execute`'s own load, its `run:` fragment load,
`flow-read-prerequisite`, `flow-add-step`'s sibling-fragment check, and
the `argent` CLI reading from another process entirely, where an
in-process lock cannot reach. Both writers used a plain `fs.writeFile`,
which opens `O_TRUNC`, so a reader could land between the truncate and
the write. That case is silent: `parseFlow("")` returns `{ steps: [] }`
with no error and `summarize` derives `ok` from "no failures", so a
`flow-execute` racing an append reports a **top-level PASS having
replayed zero steps**. Both writes now go through a sibling temp file
and a rename, so a reader sees either the whole old file or the whole
new one — and unlike the lock, that also holds cross-process.

### `stop-all-simulator-servers` gets a `devices` scope

Every agent is told to call this at session end, and unscoped it walked
the whole registry — so agent A wrapping up tore down agent B's devtools
mid-recording, degrading B's flow to brittle coordinate taps. It now
takes an optional `devices` scope and reports `unmatched` ids, so a
mistyped id doesn't read as a clean machine.

Which namespaces count as "owned by a device" turned out to be the
load-bearing part, because nothing cascades to most of them and
`unmatched` turns any omission into an actively wrong diagnosis — a
correct device id reported as a typo. The set now covers `AXService`
(the in-sim ax daemon, spawned `--timeout 3600`; an iOS session that
only ran boot/launch/describe owns this and nothing else),
`ScreenRecordingSession` (an ffmpeg child plus the touch-visualizer
overlay it enabled on the device), `NativeProfilerSession` (an xctrace
child, or an on-device perfetto process and its trace file) and
`JsRuntimeDebugger` (a bound loopback server, the CDP socket to Metro, a
log handle). The debugger URNs interpose the Metro port —
`<ns>:<port>:<deviceId>` — so the matcher understands both shapes,
consuming only the first colon so a wireless adb serial after the port
still compares whole.

`stop-simulator-server` shared none of this and had drifted: it looked
URNs up with an exact, case-sensitive `services.get()`, so a lower-cased
UDID silently no-op'd there while the scoped stop-all reaped it. Both
tools now share one matcher. Their namespace *sets* stay deliberately
different, documented where they are defined — `stop-simulator-server`
is also the documented recovery for a wedged transport, and widening it
to devtools/AX would make a routine retry drop the native-devtools
connection another agent's recording depends on, which is the hazard the
`devices` scope exists to prevent.

## Verification

**End-to-end, three devices, one shared tool-server** (iPhone 16 Pro,
iPhone 17 Pro, Pixel 3a): three interleaved recordings, plus an
unrelated `flow-execute` in a fourth project while all three were live.
Both iOS devices held their own live native-devtools concurrently (two
per-UDID sockets, `connected: true`). Each YAML contained exactly its
own steps in order, taps captured as selectors rather than coordinates,
and neither picked up another's or the third root's path. All three
replayed green — also through the **released 0.17.0 CLI** against the
modified server, which checks wire compatibility. A device-scoped
teardown of sim 1 left sim 2 and the emulator running.

Re-run against the final build:

- Two interleaved recordings across two project roots on one device,
with a **separate process** polling one of the flow files as fast as it
could for the duration. Zero empty or truncated observations; each YAML
held exactly its own steps; both replayed green (6/6 and 5/5). Against
the pre-change in-place write the same reader observes zero-step reads
interleaved among the real ones — the silent-PASS input.
- A scoped teardown of the iPhone reaped `AXService` and
`NativeDevtools` for that UDID only, reported no `unmatched`, and left
the second sim and the emulator running. Lower-casing the UDID matched;
a genuinely bogus id was still reported.
- With a screen recording live on the Pixel 3a, a scoped teardown reaped
`ScreenRecordingSession` and the **ffmpeg child actually exited** (1 →
0). Before the fix it survived to the 180 s cap while the tool called
the correct serial a typo.

The pre-change repros were re-run afterwards to confirm observed
behavior changed, not just the tests.

`npx vitest run` in `packages/tool-server` (295 files, 3154 tests) and
`packages/argent-cli` (280), `tsc --build`, `tsc --noEmit` on both test
projects, eslint and prettier — all green.

## Review notes

Several rounds of review found real defects in this change; each was
reproduced before fixing and re-verified after. Worth knowing when
reading the diff, since the tests exist to pin them:

- `flow-start-recording` truncated the `.yaml` outside any lock, so a
step from the take being discarded landed in the freshly reset file and
was reported as success (26-29 of 200 runs).
- `flow-finish-recording` never took the lock; its `await fs.readFile`
was a yield during which a concurrent append committed, leaving the
reported summary disagreeing with disk (11-16 of 200).
- A failed finish destroyed the recording. Hand-editing the `.yaml`
mid-recording is a documented workflow, so `parseFlow` can legitimately
throw — and the error told the agent to call `flow-start-recording`,
which truncates the very take it would recover.
- `unmatched` reported a device this session had just stopped, because
`disposeService` returns a node to IDLE and keeps it.
- A bare IP claimed every wireless-adb device at that address, since an
adb serial is itself `ip:port`.
- The eviction backstop stamped with `Date.now()`; ties at millisecond
resolution let it drop the session that was just used.
- `requireRecordingSession` ended with "Call flow-start-recording first"
— reached for a key that was finished, superseded or evicted as well as
one never started, and on those branches the file on disk is fully
populated while that call truncates it and reports no `restarted`.
- The atomic write regressed twice on its own terms: its scratch name
was derived from the flow file's basename, which has no length cap, so a
long flow name that appended fine before failed `ENAMETOOLONG`; and the
cleanup guard began after the temp file was created, leaking a scratch
file into a committed directory on any write that failed after opening.
- Two tests passed against implementations they claimed to reject: the
`stop-simulator-server` narrowness guard used a udid that classifies as
*android*, where the iOS-only services it guards can never appear, so
widening the iOS branch kept it green; and the client-mode finish
asserted only the *shape* of `savedTo`, never its content, though that
directive is the only thing that lands the file in client mode.

## Notes for reviewers

- Client/remote persist mode is the least-exercised path, and now has
its own concurrency tests (overlapping appends, and a restart
superseding an in-flight one) — it previously had none. The
`project_root` key is byte-stable across the file boundary (`kind:
"probe"` passes it through unchanged), which is what keeps a remote
recording addressable from `flow-add-step`, which declares no file
input.
- Two spellings of one flow file still mint two sessions — and two
independent locks — over one file, which bypasses every guarantee here.
Two ways in: the root spelled two ways (a symlink, or a case variant on
APFS), and the likelier one, the *name* cased two ways on a
case-insensitive volume. Neither is normalized away because the correct
normalization is the filesystem's: case-folding the key would wrongly
merge two distinct flows on ext4, and resolving symlinks is impossible
in client mode, where the root does not exist on this host. Documented
at `getFlowPath`.
- No recording tool can detect that another agent took its key.
`flow-add-step`, `flow-add-echo` and `flow-finish-recording` all
re-resolve the key on every call, so after a takeover they act on
whichever take is now live and report success: an append lands in the
other agent's file, and a finish finishes and clears it. The only case
that fails loudly is the narrow one the liveness check covers - an
append already IN FLIGHT when the restart lands. There is no caller
identity to key ownership on (see the rejected alternatives above), so
this is spelled out for the agent under "Pick a name unique to your
task" in `argent-create-flow` rather than mechanized.
- Skew hazard in one direction only: a *new* client against an *older*
tool-server. Zod strips the undeclared `name`/`project_root`, and the
old server falls back to its module globals — so concurrent recordings
silently collapse onto one session. The other direction (released 0.17.0
CLI against this server) was verified end-to-end.
- `packages/argent-private/docs/reference.md` still describes
`flow-add-step` as appending to "the current recording", and names two
tool ids that do not exist (`flow-insert-echo`/`flow-run` are file
names; the ids are `flow-add-echo`/`flow-execute`). It is a submodule,
so the fix cannot ride on this branch.

- A restart reported `discardedSteps` from the superseded session's
in-memory flow while it truncates the *file*. Hand-editing the `.yaml`
mid-recording is documented, so the two diverge: four steps wiped, one
reported. It now counts the file, and reports no number at all when that
file cannot be read or parsed - 0 is the answer a genuinely empty take
gives. Client mode still counts from memory (this host cannot see the
client's file), and the description now says which mode it is promising.
- `ChromiumJsRuntimeDebugger` cascades from `ChromiumCdp` and was
therefore already being torn down - silently, while `NetworkInspector`
and `ReactProfilerSession` cascade the same way and were named.
`stopped` is documented as the services that were live and got shut
down, so it is listed now too.
- The client-mode case named for a superseded in-flight append restarted
*between* calls, so the next append re-resolved the key and succeeded -
the guard was never reached, and neutering it left the whole file green.
It now parks an append in its live sub-tool call across the restart.
- `stop-tools.test.ts`' mock handed the tool the live service map where
the real `getSnapshot` copies, so a cascade rewrote the state the sweep
was still reading and the answer depended on map insertion order. The
mock copies and recurses like `_teardown` now, and the cascade case runs
under both orders.
- Prose the code contradicted, each verified against source before
rewriting: Vega owns `JsRuntimeDebugger`/`NetworkInspector` once the
debugger has run (and can match anything at all through an ERROR node);
"nothing here cascades" was denied twice in its own file; an iOS
boot/launch/describe session owns `NativeDevtools` too; `:tcp` is
mintable but unreached in production; the tool-server is a singleton
*per install bundle*, not per machine; `argent flow list` does enumerate
`.argent/flows`; the stop-tools comments narrated a "before" that only
existed on this branch.

Found while verifying, out of scope, worth their own issues: a `tool:
flow-execute` self-reference recurses past both cycle guards (`run:` is
guarded, a nested `flow-execute` reseeds the stack); react-profiler
artifacts collide host-wide on a second-resolution filename;
`stop-metro` is port-scoped and kills another agent's bundler;
`debugger-reload-metro`'s HTTP fallback broadcasts to every attached
client. A tool's `capability` is enforced only by the HTTP layer, not by
`registry.invokeTool`, so a call that reaches the registry another way -
`flow-add-step` takes `command` as a bare string - can resolve a service
the tool declares it does not support, leaving an ERROR node behind
(e.g. `SimulatorServer:<vega serial>`).

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-08-10 10:54:58 +02:00
Aayush Gautam 3795cb84fb fix(ios-profiler): stop native-profiler-stop being aborted mid-packaging (#529)
## Problem

`native-profiler-stop` fails with `No active native profiling session
found. Call native-profiler-start first.` while xctrace is still
recording — #248, #250, #330. Those were closed on the 0.12.1
`--all-processes` workaround (#380), which fixed trace finalization but
not this.

The tool isn't declared `longRunning`, so the MCP adapter applies its
per-request timeout:

```js
fetchTimeoutMs: meta?.longRunning ? null : FETCH_TIMEOUT_MS,   // mcp-server.ts:222
```

A stop that runs past 30s is aborted and retried (`MAX_RETRIES = 4`).
The retry runs against the session state the first call has already
cleared, hits the `!api.profilingActive` branch and throws. The message
points at session tracking; the actual failure is the timeout.

Every failed stop I have logs for lands at 30–34s: `durationMs: 33639`
in [#330](https://github.com/software-mansion/argent/issues/330), and
30276 / 30605 / 31019 / 31914 locally.

Stop can't fit in 30s on Xcode 26.4+. Since #380 the capture is
host-wide rather than app-scoped, so the bundle is much larger — ~55MB
per 10s of recording here, 385MB for 90s — and xctrace needs ~22s to
package even the 10s case before the four export passes start.

`STOP_GRACE_MS` has the same problem from the other side: at 30s xctrace
is SIGTERM'd mid-package, and the bundle it leaves behind fails to
export.

## Fix

Declare the tool `longRunning`, and raise `STOP_GRACE_MS` to 300s. The
grace is a ceiling rather than a fixed wait — a fast package still
returns as soon as xctrace exits — so this only changes captures that
were previously killed.

With both applied a 90s capture stops and exports cleanly in ~2–4 min,
and a 40s capture returns inline.

## Note

The abort-and-retry sequence is inferred rather than captured directly.
What's observed: `MAX_RETRIES = 4`, the uniform ~30s durations, and
`tool-server.log` showing the `did not respond to SIGINT` warning —
written after the throw site — logged 184ms *before* the failure was
returned. One execution can't produce that ordering.

## Testing

- `npm run build` and eslint clean on changed files
- `npm test -w @argent/tool-server` — 2727 tests / 273 files pass
- Verified on Xcode 26.5 / iOS 26.5 simulator against a bridgeless RN
app: start → interact → stop → analyze now returns a populated report
with `errors: {}`
2026-07-23 18:32:44 +02:00
Ignacy Łątka a5c6730d05 feat(ios-profiler): attributable memory leaks via malloc_stack_logging (#351)
## What

Opt-in `malloc_stack_logging` flag on `native-profiler-start` that makes
iOS memory leaks **attributable** — they come back with a real
responsible frame + library instead of `<Call stack limit reached>`.

## Why

`native-profiler-start` attaches to the already-running app. iOS records
malloc allocation backtraces only when `MallocStackLogging` is set **at
process launch**, so an attached app has none: Instruments' Leaks
scanner finds the leaked blocks but can't attribute them and emits the
placeholder `<Call stack limit reached>` (that string is Instruments',
not ours).

## How

When `malloc_stack_logging: true`, the profiler cold-launches the app
**under** xctrace instead of attaching:

```
xctrace record --template <Argent.tracetemplate> --device <udid> \
  --env MallocStackLogging=1 --output <…> --no-prompt \
  --launch -- <App.app>
```

- `.app` path resolved via `simctl get_app_container`; the running
instance is terminated first for a clean cold start.
- The existing Leaks export / parser / render path is reused
**unchanged** — no parser changes.
- Default behaviour is untouched: attach to the running app (no
relaunch, no overhead). The flag is opt-in because it restarts the app
(loses state) and adds allocator overhead — for leak-attribution passes,
not CPU/hang work.
- The report relabels unattributable leaks with a hint to re-run with
`malloc_stack_logging: true`, instead of surfacing the raw placeholder.

## Evidence (real iPhone 16 sim, same scroll workload)

Before (attach) — every leak row:
```
responsible-frame="<Call stack limit reached>" responsible-library=""
```
After (`--env MallocStackLogging=1 --launch`) — same Leaks export:

| leaked-object | responsible-frame | library |
|---|---|---|
| Malloc 1008 B ×3 | `itanium_demangle::OutputBuffer::grow(...)` |
libc++abi |
| Malloc 48 B ×1 | `hermes::vm::JSTypedArrayBase::createBuffer(...)` |
hermes |

## Tests

- `test/ios-instruments/malloc-stack-logging.test.ts` — the malloc
launch argv (`--env MallocStackLogging=1`, `--launch -- <app>` last,
with and without `--notify-tracing-started`, `--no-prompt`) vs default
`--attach`; terminate-first / get_app_container ordering; failed-start
behavior; per-test `ARGENT_IOS_CAPTURE` scrubbing.
- `test/ios-instruments/leak-attribution-render.test.ts` —
unattributable vs attributed leak rendering, capture-mode-aware hints.
- `test/ios-instruments/load-freshness.test.ts` — per-capture residue
clearing on `profiler-load`, live/recovery-pending load refusals.
- `test/ios-instruments/stop-recovery.test.ts` — capture-mode pairing at
stop (clean and recovery paths).
- `test/profiler-combined-leaks.test.ts`,
`test/profiler-leak-stacks.test.ts`, `test/ios-leak-pipeline.test.ts` —
shared caveat line, evidence-first hints, attribution-normalized
grouping.
- Full `test/ios-instruments/` suite green (9 files / 70 tests); full
tool-server suite green; `tsc --noEmit` + `tsc -p tsconfig.test.json`
clean.

## Notes

E2E'd live twice during review (real iPhone 16 Pro / iPhone 17 Pro sims
on Xcode 26.6): guard refusals fire before the app is touched, the
`--device` cold launch's deadlock on degraded Xcode is confirmed (so the
guard is warranted), and the malloc argv/terminate/relaunch behavior
checked out on-device. Docs updated: `IOS_PROFILER_REFERENCE.md` and the
`argent-native-profiler` skill.

## Review follow-ups (post-approval hardening)

Later commits address the remaining review threads and several
subsequent multi-lens review passes:

- **Capture-mode is a real signal, end to end.** The session records the
mode of the in-flight recording at start
(`recordingMallocStackLogging`), pairs it with `exportedFiles` at stop
(`mallocStackLogging`), and freezes it into `parsedData` at analyze/load
— so the analyze report, the combined report, and the `leak_stacks`
drill-down all name the capture mode of the data they actually render
instead of inferring it (or mislabeling it after a newer capture
starts). One shared `renderUnattributedLeaksNote()` keeps the wording
identical across reports, and attribution evidence outranks the flag: a
resolved frame proves the target ran under malloc stack logging however
it was launched.
- **`profiler-load` restores a clean slate.** Loading clears all
per-capture residue (capture mode, `cpuFilterPid`, `wallClockStartMs`,
`traceFile`) so a loaded trace is never analyzed with a previous live
capture's PID filter, freshness anchor, or report path — and refuses to
load while a recording is in flight or a crashed capture awaits
`native-profiler-stop`'s partial-trace recovery, which the load would
otherwise orphan.
- **Failed starts are non-destructive.** Per-capture descriptors — and
the recovery flags that gate `native-profiler-stop`'s partial-trace
export — are stamped/reset only on a successful start, so a failed
attempt leaves the previous capture's still-loaded exports fully
described and a pending abnormal-end recovery intact.
- **Guards keep session state coherent.** `profiler-load`,
`native-profiler-analyze`, and `profiler-combined-report` all refuse
while a recording is in flight or a crashed capture awaits recovery, via
one shared `isCaptureInFlight`/`inFlightGuardMessage` helper whose
message distinguishes recording vs 10-min-cap timeout vs unexpected exit
(so the stated cause matches what `native-profiler-stop` reports next).
Proceeding would otherwise wedge the session (load) or render one
capture's frozen data under another's live fields (analyze/combined).
- **The combined report anchors on a frozen clock.** The recording's
start time is frozen into `parsedData` at analyze, and the iOS combined
report anchors its hangs to that frozen value rather than the live
session field — so a capture started after analyze can never shift a
real hang↔commit correlation into "unmatched". Android keeps its live
anchor (it re-derives hangs from the live trace). The combined-report
guard's recovery advice routes through `native-profiler-analyze`, the
only step that rebuilds `parsedData`.
- **Leak grouping is attribution-normalized.** `aggregateLeaks` keys on
`(objectType, frame-normalized-the-way-isLeakAttributed-matches)`, so
mixed unattributed spellings (`Unknown`, the sentinel, whitespace
variants) can't split one group per type.
- **The malloc launch target is unambiguous.** Since the cold launch
terminates the resolved app, `app_process` resolution prefers an exact
`CFBundleExecutable` match and, when builds still tie (dev + prod
sharing an executable/display name), refuses and points at the
globally-unique `CFBundleIdentifier` — which it also accepts as an
`app_process` — instead of killing whichever app the plist enumerates
first.
- **Report tables escape `|`.** Demangled C++ frames (`operator|`
included) are real headline content under malloc mode; every free-form
cell across the leak, CPU-hotspot, function caller/callee, and
thread-breakdown tables — symbol *and* thread/queue-name columns — is
escaped so a stray pipe can't shift the row's columns.
- **Android start is non-destructive too.** `startNativeProfilerAndroid`
now starts perfetto before mutating session state, mirroring iOS, so a
failed start no longer burns a prior capped capture's pending
partial-trace recovery.
2026-07-12 13:42:23 +02:00
Ignacy Łątka 4670fcbb72 fix(profiler): validate app_process and flag stale Android traces (#340)
## Summary

Two native-profiler session-correctness gaps found while testing on
Android:

- **`native-profiler-start` accepted any `app_process`.** Perfetto
scopes `linux.perf` to the cmdline but never checks it exists, so a
typo'd / not-installed package recorded an empty trace whose failure
only surfaced at **analyze** time — and was misattributed to
"non-debuggable / missing `<profileable>`". An explicit `app_process` is
now validated up front (installed package or running process), failing
fast with the real reason. Auto-detection is unchanged.
- **No staleness signal.** The profiler session lives for the whole
tool-server run, so `native-profiler-analyze` happily reported on a
trace recorded days earlier (in an unrelated working session) with
nothing flagging it. Analyze now emits a "stale trace" warning when the
recording's wall-clock start is clearly from an earlier session.

## Test plan

- New `test/android-perfetto/session-correctness.test.ts` (freshness
helper, app_process validation incl. device-error path, render
pass-through).
- `npm test -w @argent/tool-server` — all pass; build +
`typecheck:tests` clean.

> Scoped to Android (the platform tested); the staleness helper is
shared and could be wired into the iOS analyze path too. Part of a set
of native-profiler fixes.
2026-06-23 10:35:41 +02:00
filip131311 732fb2ccba fix(ios-profiler): make xctrace export async so native-profiler-stop can't trip the tool-server health check (#384)
## Problem

On the newest main (0.12.1), `native-profiler-stop` on an iOS 26.5 sim
returns:

```
Missing or invalid Authorization header. Tool-server requires `Authorization: Bearer <token>`…
```

…even though the trace export actually completes and the XML lands on
disk. Reproduced profiling Settings on a 26.5 sim.

## Root cause — event-loop starvation, not a profiler bug

1. `native-profiler-stop` runs **four** `xctrace export` passes (TOC
discovery, CPU, hangs, leaks) through `execSyncWithTimeout` →
`child_process.execSync`, which **blocks the Node event loop** for the
entire export.
2. iOS 26.5 forces the host-wide `--all-processes` capture fallback (the
`xctrace --device` deadlock workaround, #380). That export is ~44 MB and
the whole stop takes **~34 s** (`durationMs: 34083` in `mcp-calls.log`).
3. While the loop is frozen, the tool-server's `GET /tools` health
endpoint can't answer. The MCP client's `ensureToolsServer()` gates
every call on `isToolsServerHealthy()` with a **hard 2 s timeout**:
   ```js
const healthy = await isToolsServerHealthy(state.port, host, 2e3,
state.token); // 2s
   if (healthy) return {…reuse…};
   await clearState();
   const token = generateToken();   // ← respawn + NEW token
   ```
4. The busy-but-alive server fails the 2 s check → the client declares
it dead, **spawns a second tool-server and rotates the auth token** in
`~/.argent/tool-server.json` → the in-flight stop is 401'd. Confirmed
live: one MCP client, *two* `tool-server.cjs` processes.

The recording **start** path already uses async `spawn` + `await`, which
is why it never trips this; only the export path was synchronous.

## Fix

Convert the four export passes to async (`exec` + `await`) so the event
loop stays free to answer health checks while `xctrace` runs. Carried
over verbatim onto the async wrapper:
- `timeout: 60_000` — still caps a genuinely-stuck `xctrace`.
- `maxBuffer: 256 MiB` — the ENOBUFS fix (#382); `exec` honors it.

`promisify(exec)` is resolved per-call (not at module load) so test
suites that `vi.doMock("child_process", …)` without an `exec` export
still import the module — matching the original lazy `execSync` usage.

This does **not** change stop latency (the export is genuinely slow) —
it makes it **non-blocking**, which is the actual bug. Concurrent calls
(health checks, screenshots) now work during a long stop.

## Not addressed here (follow-ups)

- **MCP respawn hardening:** `ensureToolsServer()` still rotates the
token / abandons in-flight requests against a *still-alive* PID the
moment a health check misses. Async export removes this trigger; the
brittle respawn behaviour is a separate fix.
- **Analyze path:** if `native-profiler-analyze` parses the 44 MB XML
synchronously it can block the loop similarly — worth checking next.

## Testing

- `tsc --noEmit` clean
- `eslint` clean on changed files
- Full tool-server suite: **1314 tests pass** (incl.
`test/ios-instruments/`, 34 tests). Updated the `exportIosTraceData`
mock from `mockReturnValue` → `mockResolvedValue`.
2026-06-19 23:06:28 +02:00
filip131311 20cb452fd4 feat(remote): artifact transport for files produced by a remote tool-server (#278)
## Why

Files produced on the **tool-server host** (screenshots, profiler
exports) were surfaced to the agent as host paths / `127.0.0.1` media
URLs, which the MCP client (`argent-mcp`) then fetched a *second* time
to get the bytes. When the tool-server runs **remotely**, that localhost
URL resolves to the client's own machine — nothing there — so
screenshots fail with `(Screenshot unavailable: no valid PNG …)`.
Profiler exports have the same shape: paths that only exist on the
remote host.

## What

A reusable **artifact boundary layer** so any file-producing tool works
regardless of where the tool-server runs:

**Foundation**
- **tool-server** (`src/artifacts.ts`, `http.ts`): in-process artifact
registry + `GET /artifacts/:id` streaming route.
`registerArtifact(hostPath)` returns a wire handle (`{ __argentArtifact,
id, filename, mimeType, size }`) instead of a raw path.
- **argent-mcp** (`src/artifacts.ts`, `content.ts`, `mcp-server.ts`): a
client materializer deep-walks tool results for handles, downloads each
over the remote-aware `TOOLS_URL`, writes it into a **structured
cache**, and rewrites the result to the **local** path (+ inline image
rendering).

**Cache layout** (override root with `ARGENT_ARTIFACTS_DIR`):
```
~/.argent/artifacts/<project>/<session>/<device>/<filename>
```
- `project` = `basename(cwd)` + short hash of the full path (readable +
collision-safe)
- `session` = minted once per argent-mcp process (groups a run, easy GC)
- `device` = udid/serial when device-scoped, omitted otherwise

**Consumers wired**
- **screenshot** → returns an image artifact; rendered inline from
downloaded bytes with **no second `127.0.0.1` fetch**. Fixes
`auto-screenshot` and `flow-execute` screenshots via the same path.
- **native-profiler-stop** → `exportedFiles` (XML) become downloadable
artifacts materialized client-side.

## Deferred (follow-ups)
- **`.trace` bundle** — it's an Instruments bundle (a directory);
streaming needs archiving (no archiver dep yet). `native-profiler-stop`
now returns a `traceFileNote` explaining it stays on the profiling host.
- **Project source on remote** — `react-profiler-component-source` /
`react-profiler-analyze` read the user's source tree on the tool-server
host. The profiler analysis chains already work remotely
(server-internal); tracked separately.

## Compatibility
Legacy `{ url, path }` screenshot rendering is retained as a fallback
when no artifact context is present (older tool-servers / unit tests).

## Tests
- tool-server: artifact registry (infer/override), `GET /artifacts/:id`
(200/404/410), updated `stop-recovery` assertions.
- argent-mcp: materializer
(download/rewrite/nested/failure/device-scoping), artifact-aware
`toMcpContent`.
- argent-mcp typecheck + full suite green; tool-server artifact/profiler
tests green. (Note: a full local tool-server typecheck trips a
pre-existing zod-v4-vs-v3 mismatch in `run-sequence` that's unrelated to
this change and resolves on this branch's own lockfile.)

## Notes
Routes around the `argent-private` native simulator-server binary (which
emits the `127.0.0.1` media URL) rather than changing it — no submodule
change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: pFornagiel <pawel.fornagiel2@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Paweł Fornagiel <85541429+pFornagiel@users.noreply.github.com>
2026-06-10 12:04:30 +02:00
Paweł Fornagiel 3ccd89f333 feat: add native android profiling (#275)
# Android native profiling via in-process Perfetto WASM

Connected with:
https://github.com/software-mansion/argent-private/pull/16

## TL;DR

Adds **Android** to the native profiler (previously iOS-only) and does
it with a
brand-new engine: instead of shipping Perfetto's `trace_processor_shell`
native
binary and forking it per query, we now run **Perfetto's trace-processor
compiled
to WebAssembly, in-process** inside the tool-server.

- **One ~13 MB `trace_processor.wasm`** runs on every OS/arch — no
per-platform
  binary, no subprocess, fully offline.
- The native profiler is refactored from an iOS-only implementation into
a
**platform-dispatch facade** (`platforms/ios.ts` +
`platforms/android.ts`) over
a new **`profiler-shared/`** layer, so iOS and Android share the
aggregator,
  types, lifecycle, and time-alignment code.
- New **release/pack pipeline** fetches + sha256-verifies the WASM
bundle from
`argent-private-releases`, and bundles it (plus SQL queries and the
Perfetto
  TraceConfig) into the published `@swmansion/argent` package.

End-to-end on a 76 MB trace with 1013 jank rows: **6.3 s** total analyze
(the older per-hang-subprocess approach was projected at ~47 minutes and
never
finished within the tool-call deadline).

---

## TESTING MANUALLY

To test manually run the following packing command checked-out on this
branch:
```
npm run pack:mcp -- argent-@pFornagiel-native-android-devtools-perfetto
```

This will bundle the package with the new binaries. After this PR is
fixed and the binaries are resolved from the main release, the
`argent-@pFornagiel-native-android-devtools-perfetto` release can be
deleted.

## Why

Android needed native CPU/hang/memory profiling to reach parity with
iOS. The
natural engine is Perfetto's trace-processor, but the obvious ways to
ship it are
both bad:

- **`trace_processor_shell` binary** — per-platform (a mac-arm64 pack
shipped a
mac-arm64-only tarball), pinned an OS/arch matrix, and cost a fresh ~1.3
s
subprocess fork **per query**. With one subprocess forked per detected
hang, a
  large trace projected to ~47 minutes.
- **`get.perfetto.dev/trace_processor` Python launcher** — adds a
runtime Python 3
dependency, resolves "latest" at first run (defeats version pinning),
and makes
  the first analyze slow via a network fetch.

The WASM engine is one cross-platform artifact, in-process and
warm-cached: the
trace is parsed **once** and reused across the whole analyze run plus
every
drill-down query.

---

## The three big pieces

### 1. In-process Perfetto WASM engine

New package surface in `@argent/native-devtools-android`:

| File | Role |
| --- | --- |
| `src/wasm-trace-processor.ts` (+476) | The whole engine: boot, glue
patching, warm-cache, fault containment, chunked parse, cell decoding |
| `src/perfetto-engine.d.ts` (+49) | Hand-written types for the
version-agnostic RPC decoder (imported by path, so tsc can't see its own
`.d.ts`) |
| `src/errors.ts` (+68) | `TraceProcessorUnavailableError`
(`wasm_load_failed` / `wasm_path_invalid`) |
| `src/bundled-meta.ts` (+12) | `PERFETTO_VERSION` stamp, regenerated at
pack time |
| `src/index.ts` | Re-exports + package-root accessors
(`traceProcessorQueriesDir`, `traceConfigPath`) |

Public surface: `resolveTraceProcessorAssets()`,
`queryWarm<Row>(tracePath, sql)`,
`ensureTraceProcessorReady(tracePath)`, `disposeWarmEngine(tracePath)`.

Highlights of how it works:

- **Boots Google's prebuilt web/worker wasm under Node.** Installs a
minimal
worker scope (`self`, `WorkerGlobalScope`, `location`), reads the wasm
bytes,
patches the Emscripten glue (`useMemory64=false`, hand the bytes in
directly,
expose the bridge), and drives it over a `MessageChannel` + `EngineBase`
subclass. Glue patches are applied **by unique string anchors that throw
if they
  don't match** — the canary for glue-format drift on a Perfetto bump.
- **Warm-engine cache, one per trace path.** LRU with `MAX_WARM_ENGINES
= 3`
(bounds resident memory at ~26–76 MB trace + wasm heap each), 5-min idle
dispose
  (timer `unref()`'d), no failed-promise caching.
- **Fault containment (`fatal` promise).** `EngineBase.fail()` throws
synchronously
inside the `MessagePort.onmessage` handler (e.g. an RPC framing error);
left
unhandled it becomes a Node `uncaughtException` and would **kill the
entire
tool-server**, wiping every device + profiler session. Each engine
exposes a
`fatal: Promise<never>`; the handler try/catches, rejects `fatal`,
evicts the
engine, and every load/query is `Promise.race`'d against it — so a
contained
  fault is a clean rejection and the server stays up.
- **Chunked trace parse.** Perfetto's RPC ring buffer rejects any frame
> 64 MiB,
  so the trace is fed in 32 MiB chunks before `notifyEof()`.

### 2. Android native profiler + the shared/dispatch refactor

**Capture** (`utils/android-profiler/capture.ts`, `detect-app.ts`):
spawns
`adb shell perfetto --txt -c - --background-wait -o
/data/.../argent-<ts>.pftrace`,
piping the TraceConfig on **stdin** (SELinux denies `shell:s0` writes
under
`/data/misc/perfetto-traces/`, so the config can't be staged as a file),
waits for
the PID on stdout, then on stop sends `kill -TERM`, polls `/proc/<pid>`,
and
`adb pull`s the trace.

**Pipeline** (`utils/android-profiler/pipeline/`): PerfettoSQL does the
parsing and
aggregation, so the Android pipeline is intentionally **two files, not
iOS's four**.
The per-hang annotation pass is **batched** — `runBatchedHangFolds`
inlines all
hang windows as a `VALUES` table into one SQL script (two derived views
unioned in
a single terminal SELECT, because the engine returns only the final
statement's
rows) and runs it once against the warm engine.

**The platform-dispatch refactor** —
`native-profiler-start/stop/analyze` shrank
from monolithic iOS files (~160–330 lines each) into **thin routers**
(~60–110
lines) that branch on `api.platform`:

```
native-profiler-start ─┬─ ios?    → platforms/ios.ts      (xctrace)
                        └─ android → platforms/android.ts  (adb perfetto)
```

A new **`utils/profiler-shared/`** layer holds everything both backends
use:

| File | What was hoisted |
| --- | --- |
| `types.ts` | Unified `Bottleneck = CpuHotspot \| UiHang \| MemoryLeak
\| MemoryRssGrowth`, each now carrying a `platform` field; shared
`NativeProfilerAnalyzeResult`; `RECORDING_CAP_MS` |
| `aggregate.ts` | Generic `aggregateCpuHotspots(AggregatorInputRow[])`
— burst windowing (`BURST_GAP_MS=500`), severity banding (RED >15%,
YELLOW 3–15%, <3% dropped). iOS feeds it via a pre-pass; Android feeds
it precomputed SQL rows |
| `lifecycle.ts` | `shutdownChild` SIGINT→SIGTERM→SIGKILL ladder +
`waitForChildExit` (no PID-reuse hazard) |
| `thread.ts` | `normalizeThreadName` for both xctrace and Perfetto
thread names |
| `time-align.ts` | New `buildPerfettoAnchor()` + `windowsOverlap()`
alongside the existing iOS/React anchors |
| `format.ts` | `formatBytes` (moved out of the combined report) |

iOS was refactored to fit this shape: `ios-profiler/lifecycle.ts` and
`types.ts`
become thin re-exports; `pipeline/02-aggregate.ts` shrank from ~200 to
~60 lines
(keeps its iOS-only dominant-function pre-pass, delegates
grouping/bursting/
severity to the shared aggregator). **No iOS behavior change** — same
outputs,
shared internals.

**Session blueprint** (`native-profiler-session.ts`): generic
`capturePid` /
`captureProcess` (was `xctracePid` / `xctraceProcess`), new `platform`
and
`androidOnDeviceTracePath` fields, and **platform-branched dispose**
(iOS SIGKILLs
xctrace; Android `kill -KILL`s perfetto, `rm`s the device trace, and
disposes the
warm WASM engine).

**Drill-down tools**: `profiler-load.ts` learns to restore Android
`.pftrace`
sessions (with an optional metadata sidecar / `app_process` override);
`profiler-stack-query.ts` splits into iOS and Android branches. Unlike
iOS (which
caches `parsedData` in memory), Android **re-queries the `.pftrace`**
per
drill-down — the warm engine is the cache, so each query costs only SQL
execution
time.

### 3. Packaging & release pipeline

The WASM bundle's three third-party artifacts (`trace_processor.wasm`,
`engine_bundle.node.js`, `engine.mjs`) + `LICENSE` are **never
committed**
(`.gitignore` lists the generated files but not the dir). They're built
and
sha256-checksummed in `argent-private` CI, published to
`argent-private-releases`, and on our side:

- **`scripts/download-trace-processor.sh`** (+88, new) — `gh release
download` →
verify tarball sha256 (**FATAL on mismatch** — the blobs are unsigned) →
extract
  → re-verify via `SHA256SUMS` → assert all four present.
- **`scripts/pack-mcp.cjs`** (+60, new) — single pack entry point:
downloads
simulator-server, native binaries, **and** the trace-processor bundle,
then
builds and `npm pack`s `@swmansion/argent`. `package.json`'s `pack` /
`pack:mcp`
  now call this script.
- **`packages/argent/scripts/bundle-tools.cjs`** (heavily reworked,
+574/-...) —
copies `assets/trace-processor/`, `assets/queries/`, and
`argent.tracecfg.pbtxt`
into `packages/argent/assets/`, and regenerates `bundled-meta.ts` to
stamp the
  Perfetto version.
- **`publish.yml` / `publish-next.yml`** — run the download step and
then **verify**:
  all four WASM files present, the wasm magic-byte check
(`head -c4 … | grep $'\x00asm'`), plus the queries dir, TraceConfig,
manifest,
  and Android helper APK.

Asset reorg: `native-devtools-android` now ships `dist/`, `bin/`, and
`assets/`
(SQL queries, TraceConfig, manifest moved under `assets/`); `.gitignore`
swaps the
old per-file `packages/argent/manifest.json` entry for the whole
`packages/argent/assets/` dir.

---

## Testing

15 new test files under `packages/tool-server/test/android-perfetto/`
cover the
engine and pipeline, including:

- `dispatch.test.ts` — start/stop/analyze route to the right platform
backend.
- `aggregate-shared.test.ts` — the shared aggregator on both iOS and
Android-shaped input.
- `hang-folds-batched.test.ts` / `hang-fold.test.ts` — the batched
per-hang SQL fold.
- `hang-severity.test.ts` — jank-reason → RED/YELLOW classification.
- `dispose.test.ts` / `dispose-warm-engine.test.ts` — session dispose
kills the daemon, rms the device trace, and tears down the warm engine.
- `stop-recovery.test.ts` — `profilingActive` is forced false even if
`adb pull` fails (so a retry-start doesn't wedge).
- `run-tp.test.ts` — `renderSqlTemplate` token rendering + the "unused
substitution" guard.
- `manifest-hint.test.ts` — zero-callstack detection emits the manifest
hint.
- `profiler-load.test.ts` — `.pftrace` session restore with/without the
metadata sidecar.

Plus `native-profiler-analyze-failure.test.ts` for the
engine-unavailable banner,
and updated iOS tests for the shared-lifecycle refactor.
`@argent/native-devtools-android` gains its own vitest setup
(`vitest.config.ts`, `tsconfig.test.json`, `test/errors.test.ts`).

---

## Notes & follow-ups

- **Manifest requirement.** Perfetto's `linux.perf` data source needs
`/proc/<pid>/mem` access, granted only to a **debuggable** or
`<profileable
android:shell="true"/>` app. A release build without either silently
produces
zero callstacks; the pipeline detects this and emits a **manifest hint**
instead
  of an "All clear" report.
- **Version pinning lives in the `argent-private` submodule**
(`PERFETTO_VERSION`
/ `PERFETTO_WASM_TAG` / `LYNX_TRACE_PROCESSOR_VERSION`). Bumping is
documented in
`argent-private/docs/ANDROID_PERFETTO.md`. JS↔engine version skew is
structurally
  impossible (version-agnostic decoder + vendored wasm).
- **`disposeWarmEngine` is exported but not wired into session-stop** —
warm
engines are reclaimed only by the idle timer + LRU cap. Acceptable today
(bounded
memory); wire it into session dispose if explicit teardown is ever
needed.
- **Submodule bump.** This PR moves `packages/argent-private` to
`22e55f90` — see the companion `argent-private` PR description,
including its
merge-hygiene note (that branch should pick up latest `argent-private`
main
  before the pointer is finalized).

### Companion docs added in this branch
- `utils/android-profiler/ANDROID_PROFILER_REFERENCE.md` — stack,
capture, queries, manifest requirement.
- `utils/android-profiler/PIPELINE_DESIGN.md` — the "why" (two-file
pipeline, shared-aggregator hoist, re-query-don't-cache, batched fold).

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:06:24 +02:00
Ignacy Łątka d017ba340c feat: Android emulator support (#148)
### For reviewers - what to focus on:

The diff is ~6k LoC; this breakdown should help triage where to spend
attention:

| Bucket | Lines | Effort |
| --- | --- | --- |
| **Net-new Android logic**: `boot-device.ts` (660) + `adb.ts` (399) +
`uiautomator-parser.ts` (292) | 1351 | **Read carefully** |
| Blueprint `DeviceInfo` refactor: `simulator-server`, `ax-service`,
`native-devtools` | 365 | Read |
| Tool `index.ts` / `types.ts` sprawl across ~20 tools (capability
gates, `services()`, descriptions) | ~965 | Skim — watch for cross-file
consistency |
| `platforms/android.ts` impls (stub → real implementation) | ~426 |
Skim |
| Skill/docs prose, profiler rename ripple | ~1000 | Skim |
| **`platforms/ios.ts` deletions** — 12 files, 0 additions; logic moved
into unified handlers | 684 | **Skip — mechanical** |

## What works, what doesn't:

### Just works thanks to either sim server or metro:
- `gesture-*`
- `button`, `keyboard`
- `screenshot`
- `run-sequence`, `flow-*`
- `debugger-*`
- `react-profiler-*`

### Adjusted to work on android too:

| tool | android | ios |
| --- | --- | --- |
| `describe` | `uiautomator dump` → `DescribeNode` | `AXServiceApi` |
| `launch-app` | `cmd package resolve-activity` + `am start -W` |
`simctl launch` |
| `restart-app` | `cmd package resolve-activity` + `am force-stop` + `am
start -W` | `simctl terminate` + `simctl launch` |
| `reinstall-app` | `adb install -r` (+`-g`/`-d`) | `simctl uninstall` +
`simctl install` |
| `open-url` | `am start -a VIEW -d <url>` | `simctl openurl` |
| `list-devices` | `adb devices` + `emulator -list-avds` | `xcrun simctl
list --json` |
| `boot-device` | snapshot probe → `-force-snapshot-load` hot boot with
screencap-alive guard, falls back to cold boot on any failure | `simctl
boot` + `bootstatus -b` |

### Will not work on android for now:
- `native-devtools-*` (7 total)
- `native-profiler-*` (3 total — was `ios-profiler-*`)
- `profiler-stack-query`, `profiler-combined-report` (depend on
`native-profiler-*`)
- `paste` — unregistered on both platforms for now.

---------

Co-authored-by: Paweł Fornagiel <pawel.fornagiel2@gmail.com>
2026-05-11 11:39:10 +02:00
Paweł Fornagiel 55d1567d48 feat: improve iOS profiler startup, shutdown, and exit recovery (#181)
## Summary

Refactor of the iOS profiler (`xctrace`-driven) session to make start,
stop, and unexpected-exit handling proper, without the iOS hanging
infinitely.

### Startup (`ios-profiler-start`)
- **Locale ready signal**: subscribe-before-spawn to a Darwin
notification (`com.argent.ios-profiler.started.<pid>.<ts>`) via
`notifyutil`, passed through `xctrace --notify-tracing-started`. Falls
back to the existing stdout substring match (`Starting recording` /
`Ctrl-C to stop`) if `notifyutil` fails to spawn or register within 2 s.
- **Startup timeout**: new 10 s cap on reaching the ready state — on
timeout, `SIGKILL` the child so it cannot leak.
- **Bounded `simctl` calls**: `detectRunningApp` now applies a 10 s
timeout to both `xcrun simctl spawn ... launchctl list` and `xcrun
simctl listapps`, with explicit error messages telling the user to
verify the simulator is booted.
- **Cold-start retry**: if xctrace's process resolver fails with
`"Cannot find process matching name:"` (launchd has registered the
bundle but xctrace's resolver hasn't caught up), the tool retries once
after a 1.2 s backoff — up to 2 attempts total. On exhausted retries
throws a classified error naming the cold-launch race and pointing at
`app_process` as the override. Non-cold-start failures propagate
immediately without retry.
- **Exit listener installed**: every `xctrace record` child gets a
long-lived `'exit'` handler (`handleXctraceExit`) that marks the session
as `recordingExitedUnexpectedly` and stores `lastExitInfo`.

### Shutdown (`ios-profiler-stop`)
- **SIGINT → SIGTERM → SIGKILL ladder** via
`utils/ios-profiler/lifecycle.ts::shutdownChild` (30 s grace, 5 s term,
5 s kill).
- **Returns a `warning`** when the ladder had to escalate past SIGINT,
so callers know the trace bundle may be incomplete.

### Exit recovery
- **New `recordingTimedOut` / `recordingExitedUnexpectedly` flags** on
the session API. If `ios-profiler-stop` is called after either fired,
the tool now exports whatever `.trace` bundle is on disk and returns it
with a descriptive `warning` instead of throwing "no active session".
- Covers the in-process 10-min recording cap, attached-app crashes, and
simulator daemon hiccups.

### Dispose semantics
- Registry teardown (process shutdown) skips SIGINT and goes straight to
`SIGKILL` with a 1 s reap window. The explicit `ios-profiler-stop`
contract is the only path that takes the slow finalise grace.

### Other
- `utils/ios-profiler/run-with-timeout.ts`: `execSyncWithTimeout`
wrapper with a 60 s default — guards every shelled-out `xctrace export`
/ `xctrace version`.
- New reference doc `utils/ios-profiler/IOS_PROFILER_REFERENCE.md`
covering the native foundations, capture flow, and pipeline.

## Tests
New unit tests under `packages/tool-server/test/ios-instruments/`

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 09:26:57 +02:00
Stanislaw Bialecki 7cb6e7b457 Ios instruments profiler (#21)
* feat:  rename previous profiler tools to react-profiler

* feat: add ios-instruments profiler with default tracetemplate

* fix: copying Argent.tracetemplate on install

* feat: enhance CPU profiling capabilities in react-profiler and ios-instruments + initial enable re-querying (investigating) the profiler output

* feat: enable analyzing old profiling sessions, enhance profiling tools interoperability

* refactor: stream react profiler data to files instead of runtime aggregate var

* chore: add xml-parser test

* chore: rm clg

* feat: ad ios-instruments timeout

* chore: cleanup reactProfilerAnalyze warning cond

* refactor: ensure not dividing by 0 in profilerCombinedReportTool

* fix: inject react commit-capture hook before startProfiling

* fix: rm main signature from rn framework trace analysis

* chore: writeReport fail

* fix: potential sampleIdx in parseCpuXml

* fix: timeDeltas[i] potentially undefined

* fix: decodeXml amp resolution sequence

* fix: rm rn-profiler skill from local repo

* feat: rename instruments to ios-profiler + improve react session stability
2026-03-19 11:16:22 +01:00