Skip to content

fuzz(restore): diskfull app create probe runs New Workspace as a person - #30531

Merged
lawrencecchen merged 6 commits into
feat-cmux-nextfrom
fuzz/fgh-appprobe2
Nov 1, 2026
Merged

lawrencecchen merged 6 commits into
feat-cmux-nextfrom
fuzz/fgh-appprobe2

Conversation

@lawrencecchen

Copy link
Copy Markdown
Contributor

Summary

The diskfull restore fuzz (#30208) gets an app-path create probe. With the disk full, it runs New Workspace the way a person does (debug.toast {perform:"newTab"}, origin user) and checks two things:

  • the app keeps no ghost workspace, now and after a relaunch (finding h1);
  • the person sees a refusal toast or dialog (finding h3).

Before the probe runs, the harness fills the disk until the daemon refuses a CLI create (margin 0 if it must). If no refusal comes, or the app's create is accepted, the probe is "not run". It is never a pass or a fail in that case. An unknown action id is also "not run".

Harness only: scripts/fuzz/restore/. No product code, and no CI job, step or gate.

Evidence (scheduler rounds, perf minis)

Changelog

none

Observed / Not run

  • Observed: the three rounds above ran this harness at 855dff1, which has the same content as this head. prepush-gate: no new violations. py_compile: clean.
  • Not run: test_restore_fuzz.py for this head (it is a harness unit test; the rounds ran the code paths).

…ction.run newWorkspace) on the full disk must leave no ghost in the app now or after a relaunch, and must show a toast or dialog; a failed probe is a violation
…newWorkspace (the toolbar button's); an unknown action makes the probe 'not run', never fail or pass; the visible-refusal reader reads debug.toast's per-window toasts and refusals
…a CLI create (margin 0 if needed), and is 'not run' when no refusal happens or the app's create is accepted
….toast perform, origin user), so it gets the refusal a person gets
…eeds a typed disk-full refusal of a CLI create, counts only a new refusal notice about the create (multiset diff), is 'not run' when a view cannot be read (also after the relaunch), fails on no app answer instead of crashing, turns an unmounted image into harness_invalid, closes an accepted create, trims the filler back to --disk-margin-mb, checks the lease and writes its record and violations.json for every verdict; tests cover each path
@lawrencecchen lawrencecchen added the risk:low Risk tier (docs/ci/risk-tiers.md) label Nov 1, 2026
@lawrencecchen

Copy link
Copy Markdown
Contributor Author

Review (claude-review-subagent): FIX at 0c64846

Static review only (diff read against origin/feat-cmux-next, plus DebugToast.swift and ActionRegistry+Perform.swift at the head; py_compile clean). I did not run the tests or a round.

The normal path works: CLI refused, newTab performed, then a toast/HUD, no ghost now or after the relaunch, gives a pass. Accepted and unknown-action cases give "not run". The image and filler are cleaned up on every exit path, because start_chaos() runs inside main()'s try/finally teardown_diskfull(). But some paths crash the run, and some can give a false pass.

  1. scripts/fuzz/restore/restore_fuzz.py:1501: the run crashes when the action call times out. rpc() returns {"error": "<str>"} on any OSError/ValueError (for example a 20 s socket timeout when the app hangs on the full disk). Then (answer.get("error") or {}).get("code") calls .get on a str and raises AttributeError. The exception leaves start_chaos() without a handler. Teardown still runs, but the process exits 1 (the "violations" exit code) and prints no SUMMARY line. A hang is the case this probe most needs to report. Fix: err = answer.get("error"); code = err.get("code") if isinstance(err, dict) else None. Record any other error answer (timeout, transport) as an explicit verdict, "fail" (no answer in 20 s is a hang) or "not run", and never fall through to the judge.

  2. restore_fuzz.py:1480: an uncaught HarnessInvalid also crashes the run. vol.fill(margin) raises HarnessInvalid when the mount is gone. setup_diskfull() and the disk_phase() call site catch it and exit 75 (harness invalid). The probe does not, so the run again exits 1 with no summary. Fix: catch HarnessInvalid in the probe, or around the app_create_probe() call in start_chaos(), and take the same path as the other call sites (summary with harness_invalid=1, exit 75).

  3. restore_fuzz.py:1482: an answer that is not a refusal counts as the daemon's refusal. Any dict with "error" counts: cli()'s own {"error": "no daemon socket"} (the daemon is dead), {"error": "no answer within 10 s", "stall": True} (a hang), and non-JSON output. With a dead daemon, the app's New Workspace can show a "not connected" refusal and leave no ghost, so the probe gives a pass for a full-disk refusal that never happened. Fix: accept only a typed refusal: "error" in answer and not answer.get("stall") and typed_error(answer). Better still, also require a disk-full code or text. Save that answer in the record as evidence, and treat any other answer as "not run".

  4. restore_fuzz.py:1509-1510 and 1439-1441: when the app cannot be read after the relaunch, the after-relaunch ghost check passes with nothing checked. The result of wait(... app_state()[1] ...) (topology loaded) is thrown away. app_view() turns a failed snapshot.get (an error dict) or a topology that has not loaded into app = set(). Then app - daemon is empty, so "no ghost after the relaunch" passes without a check. The same applies to after if snapshot.get fails there. That half of h1 (a saved ghost record) can then pass falsely. Fix: have app_view() return readable: False when snapshot.get errors or topology.loaded is not true. Then judge_app_create (or the probe) adds a problem such as "the app's topology could not be read after the relaunch", or makes the verdict "not run", and never a pass.

  5. restore_fuzz.py:1448-1453 and 1467: an unrelated notice can satisfy the "visible refusal" check. The check counts all notices: refusals is refusalHUD.history, which holds every refusal so far for any action, capped at the newest 100 (RefusalHUD.swift:35). Window toasts and dialogs are counted the same way. Any toast or HUD for another cause in the 5 s window counts as the create's refusal: for example a save-failed or daemon-state toast, which is likely on a full disk, or an async notice from the CLI refusal that arrives after before. That gives a false pass for h3. The count also stops at 100 history entries, and a toast that expires and is replaced keeps the count the same. Both give false fails. Fix: compare the notice texts (as a multiset of texts from before to after) and require a new entry that names the create (for example "New Workspace" or the disk-full reason, as in your round's text "New Workspace did not apply: the disk is full"). Record which entry matched.

  6. scripts/fuzz/restore/test_restore_fuzz.py:409-439: the unit tests do not cover the probe's control flow. They cover judge_app_create and the app_view parse only. Nothing tests the decisions in app_create_probe(): "not run" when the CLI create is never refused, when performed is false, when the action is unknown, and when the app's create is accepted (this is in the probe, not in the judge, so test_created_for_real_is_not_a_ghost does not prove it). Nothing tests that a relaunch failure gives a fail, or the string-error answer from finding 1. Fix: add tests that stub rpc, cli, DISK["volume"] (a fake with fill), APP (relaunch, daemon_pids, acpmux_pids), stop_build_processes, time.sleep and wait, and assert the verdict for each branch. Include findings 1, 3, 4 and 5 as regression cases.

Non-blocking:

  1. restore_fuzz.py:1479-1488: the probe leaves the filler at margin 0 for the iterations. DISK["phase"] stays "full", so disk_phase() does not fill again. Iterations 0-6 (with --disk-recover-every 8) then run at 0 MB free, not at --disk-margin-mb. This can change their results and make the "three not run in a row" stop more likely. Fix: after the probe, run vol.unfill(); vol.fill(opts.disk_margin_mb).

  2. restore_fuzz.py:1472-1527: the probe has no deadline check. In the worst case (three app_views of about 70 s each, the 20 s action, the 60 s relaunch, the 30 s load wait, the fill loop), it takes about 400 s on top of the setup, so the run can go past the default --max-seconds 480 before the first iteration starts. Fix: skip the probe as "not run" when T0 + opts.max_seconds - time.time() is below the probe's bound.

  3. restore_fuzz.py:1513-1517: when the app's create is accepted, the probe never closes the workspace. The "not run" path leaves it in the daemon on the full disk. Close it as the CLI path does at line 1487.

  4. restore_fuzz.py:1490-1505, 1513-1517: app-create-probe.json is written only for pass and fail, and a probe fail is not added to violations.json, which is written only inside the loop. Write the record on every path, and write violations.json again after the probe violation is added in main().

  5. restore_fuzz.py:1473 and test_restore_fuzz.py:410: two docstrings still say action.run newWorkspace. The code runs debug.toast {perform: "newTab"}.

@lawrencecchen

lawrencecchen commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Review (claude-review-subagent): SHIP at 1c3b15b

Re-review of the repair delta 0c64846..1c3b15b, with the whole probe read again. Static review only: py_compile is clean. I did not run the unit tests. I also read debug.toast (DebugToast.swift), the daemon's disk-full answers (journal_ingress/disk_full.rs) and verdict.py at the head.

All six blocking findings from my FIX at 0c64846 are fixed:

  1. A bare-string rpc() error is now an explicit "fail" with a record. It no longer crashes.
  2. HarnessInvalid is caught at the start_chaos() call site. The run then tears down and exits 75. The trim in finally cannot hide it: the mount point dir still exists, so statvfs works, and the check need > 0 against the host's free space does nothing.
  3. Only a typed disk-full answer that is not a stall counts as the daemon's refusal (disk_full_refusal).
  4. An unreadable before, after or relaunch view gives "not run". So does a relaunch that never loads. An empty app set can no longer pass the ghost check.
  5. Only a new notice counts: a multiset difference with a word filter.
  6. AppCreateProbeDecisions tests each branch, and the earlier review findings are among the cases.

The earlier non-blocking findings 7-11 are also fixed: the filler is trimmed back, the probe checks the time left before it starts, an accepted create is closed, every verdict writes app-create-probe.json, a probe fail rewrites violations.json, and the docstrings are corrected. verdict.py reads violations= with a regex, so a probe fail becomes FAIL, and the space in app_create_probe=not run does not break parsing.

I found no path that gives a false pass, crashes the run, or leaves the image mounted or the filler in place.

Non-blocking:

  1. scripts/fuzz/restore/restore_fuzz.py:1469 (DISK_FULL): the pattern misses one of the daemon's own disk-full refusals. When the journal is already flagged full, a waiting caller gets WAITER_ERROR = "the session journal is out of disk space; try again once there is room" (cmux-tui-core/src/journal_ingress/disk_full.rs:23). It has no "disk is full", "no space left" or "ENOSPC". Background terminal events can keep the flag up, and then all three CLI creates can get only this text. The probe then shows "not run", although the daemon did refuse for the full disk. This errs to the safe side (never a false pass), but the probe can stop checking without a sign. Fix: add out of disk space to DISK_FULL.
  2. restore_fuzz.py:1468 (CREATE_REFUSAL_WORDS): the filter is loose. space matches inside workspace, so any new toast or dialog whose JSON mentions a workspace counts as the refusal. Today, no such notice shows on a refused New Workspace: cloud-new-workspace-here shows only after a create succeeds (AppActions+Workspaces.swift:177). So there is no false pass now, but a future toast could make one. Fix: require refusal wording, for example did not apply|disk|no space|out of disk space, not workspace|space|create.
  3. restore_fuzz.py:1508 (PROBE_BOUND): the bound only gates the start, and the probe has no timer of its own. In the worst case (three views of about 70 s each, the 20 s action, the 60 s relaunch, the 30 s load wait, three CLI creates), it takes about 355 s, more than 240. The verdict is still correct, because the loop then starts no iterations. Fix: check the deadline again before the relaunch, or lower the per-call timeouts in the probe.
  4. restore_fuzz.py:1556-1560: the no-answer "fail" returns without a relaunch. The iterations after it then start with an app that may still hang, and their failures will come from the probe's hang. Fix: on that path, stop the app's processes and relaunch the app before you return, as the main path does.

@lawrencecchen lawrencecchen added the ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds label Nov 1, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

5 issues found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/fuzz/restore/test_restore_fuzz.py">

<violation number="1" location="scripts/fuzz/restore/test_restore_fuzz.py:490">
P2: This fixture does not verify that the probe invokes the person-facing New Workspace action: a regression to the wrong RPC method or payload would still pass every added test. Make the mock assert `method == "debug.toast"` and `params == {"perform": "newTab"}` before returning the configured response.</violation>
</file>

<file name="scripts/fuzz/restore/restore_fuzz.py">

<violation number="1" location="scripts/fuzz/restore/restore_fuzz.py:1449">
P2: `app_view` treats incomplete topology responses as readable, so the probe can judge an empty baseline instead of proving both views were read. Require explicit workspace-list shapes for the app and daemon before setting `readable` true.

(Based on your team's feedback about incomplete snapshot workspace lists.) [1391804a-5457-4c80-8025-dcfde26134cd]</violation>

<violation number="2" location="scripts/fuzz/restore/restore_fuzz.py:1482">
P2: The visible-refusal check counts any new notice mentioning workspace, storage, or create, including informational notices. Require refusal/error wording in addition to the create or disk-full context before treating the person-visible check as passed.</violation>

<violation number="3" location="scripts/fuzz/restore/restore_fuzz.py:1545">
P2: The probe only closes successful creates returned as `workspace_id`; a `created_workspace_id` response leaks the temporary workspace into later iterations. Read `created_workspace_id` first, with `workspace_id` as the compatibility fallback.

(Based on your team's feedback about workspace create response IDs.) [c35e470a-df15-44af-906f-a38221be6485]</violation>

<violation number="4" location="scripts/fuzz/restore/restore_fuzz.py:1578">
P2: This predicate can crash during a normal relaunch gap because it reads `app_state()` twice and indexes the second result. Capture one state per poll before checking its loaded flag.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

return {}, 0.1

rf.cli = fake_cli
rf.rpc = lambda m, p=None, timeout=20: action if action is not None else {"performed": True}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This fixture does not verify that the probe invokes the person-facing New Workspace action: a regression to the wrong RPC method or payload would still pass every added test. Make the mock assert method == "debug.toast" and params == {"perform": "newTab"} before returning the configured response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/fuzz/restore/test_restore_fuzz.py, line 490:

<comment>This fixture does not verify that the probe invokes the person-facing New Workspace action: a regression to the wrong RPC method or payload would still pass every added test. Make the mock assert `method == "debug.toast"` and `params == {"perform": "newTab"}` before returning the configured response.</comment>

<file context>
@@ -406,6 +406,162 @@ def test_stall_or_loss_in_not_run_fails(self):
+            return {}, 0.1
+
+        rf.cli = fake_cli
+        rf.rpc = lambda m, p=None, timeout=20: action if action is not None else {"performed": True}
+        rf.app_view = lambda: views.pop(0)
+        rf.stop_build_processes = lambda exclude=(): None
</file context>
Suggested change
rf.rpc = lambda m, p=None, timeout=20: action if action is not None else {"performed": True}
def fake_rpc(method, params=None, timeout=20):
if method != "debug.toast" or params != {"perform": "newTab"}:
raise AssertionError((method, params))
return action if action is not None else {"performed": True}
rf.rpc = fake_rpc

counts as "no ghost": the probe makes it "not run"."""
snap = rpc("snapshot.get")
topo = snap.get("topology") if isinstance(snap, dict) else None
app_ids = {w.get("id") for w in (topo or {}).get("workspaces", []) if isinstance(w, dict) and w.get("id")}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: app_view treats incomplete topology responses as readable, so the probe can judge an empty baseline instead of proving both views were read. Require explicit workspace-list shapes for the app and daemon before setting readable true.

(Based on your team's feedback about incomplete snapshot workspace lists.) [1391804a-5457-4c80-8025-dcfde26134cd]

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/fuzz/restore/restore_fuzz.py, line 1449:

<comment>`app_view` treats incomplete topology responses as readable, so the probe can judge an empty baseline instead of proving both views were read. Require explicit workspace-list shapes for the app and daemon before setting `readable` true.

(Based on your team's feedback about incomplete snapshot workspace lists.) [1391804a-5457-4c80-8025-dcfde26134cd]</comment>

<file context>
@@ -1433,6 +1440,154 @@ def recover_after_space(target, old_daemons):
+    counts as "no ghost": the probe makes it "not run"."""
+    snap = rpc("snapshot.get")
+    topo = snap.get("topology") if isinstance(snap, dict) else None
+    app_ids = {w.get("id") for w in (topo or {}).get("workspaces", []) if isinstance(w, dict) and w.get("id")}
+    wsl, _ = cli("workspace", "list")
+    daemon_read = isinstance(value(wsl), list) or (isinstance(wsl, dict) and "error" not in wsl)
</file context>

for t in after.get("texts") or []: # a multiset difference: the same refusal shown again is new
if old.get(t):
old[t] -= 1
elif CREATE_REFUSAL_WORDS.search(t):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The visible-refusal check counts any new notice mentioning workspace, storage, or create, including informational notices. Require refusal/error wording in addition to the create or disk-full context before treating the person-visible check as passed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/fuzz/restore/restore_fuzz.py, line 1482:

<comment>The visible-refusal check counts any new notice mentioning workspace, storage, or create, including informational notices. Require refusal/error wording in addition to the create or disk-full context before treating the person-visible check as passed.</comment>

<file context>
@@ -1433,6 +1440,154 @@ def recover_after_space(target, old_daemons):
+    for t in after.get("texts") or []:  # a multiset difference: the same refusal shown again is new
+        if old.get(t):
+            old[t] -= 1
+        elif CREATE_REFUSAL_WORDS.search(t):
+            fresh.append(t)
+    return fresh
</file context>

if disk_full_refusal(answer):
refused = answer
break
made = value(answer).get("workspace_id") if isinstance(value(answer), dict) else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The probe only closes successful creates returned as workspace_id; a created_workspace_id response leaks the temporary workspace into later iterations. Read created_workspace_id first, with workspace_id as the compatibility fallback.

(Based on your team's feedback about workspace create response IDs.) [c35e470a-df15-44af-906f-a38221be6485]

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/fuzz/restore/restore_fuzz.py, line 1545:

<comment>The probe only closes successful creates returned as `workspace_id`; a `created_workspace_id` response leaks the temporary workspace into later iterations. Read `created_workspace_id` first, with `workspace_id` as the compatibility fallback.

(Based on your team's feedback about workspace create response IDs.) [c35e470a-df15-44af-906f-a38221be6485]</comment>

<file context>
@@ -1433,6 +1440,154 @@ def recover_after_space(target, old_daemons):
+        if disk_full_refusal(answer):
+            refused = answer
+            break
+        made = value(answer).get("workspace_id") if isinstance(value(answer), dict) else None
+        if made:
+            cli("workspace", made, "close")
</file context>
Suggested change
made = value(answer).get("workspace_id") if isinstance(value(answer), dict) else None
made = (value(answer).get("created_workspace_id") or value(answer).get("workspace_id")) if isinstance(value(answer), dict) else None

"no refusal to judge"], action_answer=json.dumps(answer)[:400])
stop_build_processes(exclude=set(APP.daemon_pids()) | set(APP.acpmux_pids()))
relaunched_ok = APP.relaunch()
loaded = relaunched_ok and wait(lambda: app_state() and app_state()[1], 30, 0.5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This predicate can crash during a normal relaunch gap because it reads app_state() twice and indexes the second result. Capture one state per poll before checking its loaded flag.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/fuzz/restore/restore_fuzz.py, line 1578:

<comment>This predicate can crash during a normal relaunch gap because it reads `app_state()` twice and indexes the second result. Capture one state per poll before checking its loaded flag.</comment>

<file context>
@@ -1433,6 +1440,154 @@ def recover_after_space(target, old_daemons):
+                                        "no refusal to judge"], action_answer=json.dumps(answer)[:400])
+    stop_build_processes(exclude=set(APP.daemon_pids()) | set(APP.acpmux_pids()))
+    relaunched_ok = APP.relaunch()
+    loaded = relaunched_ok and wait(lambda: app_state() and app_state()[1], 30, 0.5)
+    relaunched = app_view() if loaded else None
+    if not relaunched_ok:
</file context>
Suggested change
loaded = relaunched_ok and wait(lambda: app_state() and app_state()[1], 30, 0.5)
loaded = relaunched_ok and wait(lambda: (lambda state: state and state[1])(app_state()), 30, 0.5)

@github-actions github-actions Bot added risk:normal Risk tier (docs/ci/risk-tiers.md) and removed risk:low Risk tier (docs/ci/risk-tiers.md) labels Nov 1, 2026
@lawrencecchen
lawrencecchen merged commit f83a90f into feat-cmux-next Nov 1, 2026
112 of 118 checks passed
@lawrencecchen
lawrencecchen deleted the fuzz/fgh-appprobe2 branch November 1, 2026 09:55
@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

cmux status (one comment, edited in place; each line is updated by the job that owns it)

Review 1c3b15b: HQ feed card · gallery before/after runs · checks

App of this head (app.zip, kept 1 day)

gh workflow run reload-build.yml -R manaflow-ai/cmux --ref feat-cmux-next -f tag=pr30531 -f ref=fuzz/fgh-appprobe2 -f platform=macos -f backend_mode=local

Shipped in:

This branch was previously deployed

1 inactive deployment
ci-routing-agent — 1c3b15b1 Deployed Nov 1, 2026 by lawrencecchen via wait for same-key tree publisher #94974
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-land Reviewed at this head; next-autoland merges it into feat-cmux-next when its only reds are base reds risk:normal Risk tier (docs/ci/risk-tiers.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant