Skip to content

Task 124: the iOS bridge has no silent paths — every guarded early return reports - #276

Merged
falcon4ever merged 4 commits into
mainfrom
task-124/no-silent-paths
Oct 1, 2026
Merged

falcon4ever merged 4 commits into
mainfrom
task-124/no-silent-paths

Conversation

@falcon4ever

@falcon4ever falcon4ever commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

What & why

Task 124 (kanama-tasks 124-ios-bridge-no-silent-paths.md): task 117 P2′ showed the iOS bridge's default failure mode is silence
(every shared-tree static was a no-op for six days; a self-test row passed only because a guard returned quietly). After this PR a
wrong call into ios/bootstrap/kanama_ios_shim.c never returns quietly:

  • Fault sink (kanama_ios_fault(entry, reason, detail)): counts every reporting early return, keeps the last fault, logs
    [kanama][ios][c] FAULT <entry>: <reason> <detail> (log capped at 64 lines; the count keeps counting; nothing resets it).
    Exported kanama_ios_fault_count() / kanama_ios_last_fault(); Kotlin ObjectCalls.faultCount() / lastFault().
  • Every guarded early return reports: 219 guarded returns across 83 functions (131 exported entry points + 24 _dispatch
    bodies), 217 reporting, 2 documented benign (is_instance_id_valid(0); an empty Callable's null target), plus 47 more in the
    static helpers behind exported entries and lookup-time bind-lookup-failed Class.method hash=… reports in
    get_method_bind / get_builtin_method. Ten reason tokens: api-unresolved, null-bind, bind-lookup-failed,
    null-instance, null-handle, null-arg, pending-protocol, callable-build, unknown-tag, encode-failed.
  • Gate scripts/check_ios_shim_faults.py (local_ci stage, gates index): fails when a guarded early return in an exported or
    _dispatch function does not call the sink (red run in the commit).
  • Self-test: seven permanent deliberate probes in one fault-probes begin/end window — a wrong-hash lookup
    (bind-lookup-failed), a null-bind ptrcall (null-bind), and the five pre-existing drained-pending-slot rows, which the review
    showed were red runs of the pending-protocol guard all along (faults=7 expected=2 would have failed every debug run); both
    summary lines print faults=F expected=E (7/7 on debug builds; release builds print neither line).
  • Desktop mirror: getMethodBind logs [kanama:kt] FAULT bind-lookup-failed … on a NULL lookup.
  • 119 item 36: the three orphaned tweener shim entries, their binds and hash constants deleted (no Kotlin reference).
  • Paired kanama-demos PR: the device runner fails on any FAULT line outside the probe window or on faults != expected.

Rulings recorded in the task spec: probe window instead of text whitelisting; second benign case; encode-failed token; the three
documented value-policy fallthroughs (un-decoded blob/variant types) stay silent and go to task 125's coverage design.

Checklist

  • scripts/local_ci.sh <godot> to green — running on the head.
  • Up to date with main (cut from 27b8b17).
  • ABI / memory-ownership call-out: the shim gains one static sink + two exported getters; every instrumented early return keeps
    its exact return value and signature; three dead exported functions removed (header + definitions); no change to any dispatch
    body's success path. iOS ObjectCalls gains two accessors and four self-test rows.
  • Docs / CHANGELOG: docs/exporting/ios.md "When you see a FAULT line", docs/contributing/backends/ios.md sink contract and
    probes, gates index regenerated (also corrects the stale first-landed date of check_ios_static_dispatch.py).

Testing

Gates PASS (fault gate 217/215/2; static-dispatch 26/24; drift; ObjectCalls parity; PT tags; doc claims; gates index);
clang -fsyntax-only no new warnings; compileKotlinJvm, compileKotlinIosArm64, createIosDeviceDebugXcframework PASS.
Red runs: gate (one sink call removed → FAIL), runner (extra FAULT line → FAIL; faults=3 expected=2 → FAIL; 2/2 → PASS) — the
runner red run caught a regex-vs-literal bug in the first cut of the check. Independent review (one Opus agent): one blocker (the five drained-slot rows) and four should-fixes, all applied in the follow-up
commit: the gate now requires the sink call to precede the return on the same path (two new red runs), the runner fails on an
unterminated probe window or a missing summary line (two new red runs), two degenerate detail ternaries split, the counter is an
atomic saturating int. Pending: local_ci on the head, iPhone 12 smokes (expect faults=7 expected=7 on both summary lines and
exactly seven FAULT lines inside the window), CI.

AI assistance

Written with Claude Code (Opus worker, Fable 5.1 coordinator: contract table + rulings), reviewed by the maintainer.

🤖 Generated with Claude Code

falcon4ever and others added 4 commits September 22, 2026 22:50
…turn reports

ios/bootstrap/kanama_ios_shim.c is the whole iOS surface and its default failure mode
was silence. 131 exported entry points and 24 static _dispatch bodies opened with 217
guarded early returns that handed back 0 / -1 / nothing when the engine API had not
resolved, the MethodBind was zero, the instance was zero, a C-string parameter was NULL
or nothing was pending — and said nothing. That is how every Godot static method reached
through the shared wrapper tree stayed a no-op on iOS for six days behind 205 green
self-test checks and two green demo smokes (task 117 P2', 119 item 35), and how a
self-test row passed for the wrong reason because a guard returned quietly (item 37).

One sink near the top of the shim (kanama_ios_fault) now takes an entry, a fixed reason
token and an optional detail; it bumps a process-wide counter that is NEVER reset and
prints "[kanama][ios][c] FAULT <entry>: <reason> <detail>" for the first 64 faults (a
tight loop must not flood the console; the count keeps counting). 215 of the 217 guarded
returns call it before returning exactly what they returned before — return values and
signatures are unchanged. Composite conditions are SPLIT so each term reports its own
reason: `!resolve || method_bind == 0` becomes api-unresolved and null-bind, and a run of
`g_x == NULL` terms names the pointer that was actually missing.

Reason tokens, all fixed spellings: api-unresolved, null-bind, bind-lookup-failed,
null-instance, null-handle, null-arg, pending-protocol, callable-build, plus unknown-tag
(an unknown PT tag boxed as nil in kanama_ios_pt_arg_to_variant, which used to warn
without counting) and encode-failed (a container argument that would not encode).

Bind lookups report at LOOKUP time, where the detail is actionable:
kanama_ios_godot_get_method_bind and _get_builtin_method report bind-lookup-failed with
"Class.method hash=<hash>" whenever the engine returns NULL, not only when the API did
not resolve. By the time the zero bind reaches a ptrcall entry point all that is left to
say is null-bind. kanama_ios_get_method_bind_cached routes through the same entry, so
the shim's own cached lookups report too.

Exactly two returns stay silent, each documented in its own function comment and listed
in the gate's BENIGN table: is_instance_id_valid(0), where a zero id is a legitimate
question whose honest answer is "invalid", and the target == NULL path of
ptrcall_ret_callable_dispatch, where an empty object-less Callable is a legitimate value
(desktop's readCallable returns null there too). The second is not in the contract table:
faulting it would fire on every healthy read of an empty Callable and break the
faults == expected contract below.

Kotlin: ObjectCalls.faultCount() / lastFault() over the new cinterop exports, and the
level-2 self-test ends with two DELIBERATE wrong calls — getMethodBind("Node3D",
"set_visible", 1L) with a wrong hash, then ptrcallWithBoolArg through the resulting null
bind — each asserted to raise the counter by exactly one and to name its reason in
lastFault(). Every other row in that file proves a call works; these two prove that a
call that does not work says so, on the device, on every debug build. Both summary lines
now end with "faults=<count> expected=2". The probes print two real FAULT lines, so they
are bracketed by "fault-probes begin/end" printlns: the device runner fails on any FAULT
line OUTSIDE that window, which keeps null-bind — the most common real fault — fatal
instead of whitelisted by its text.

Desktop gets the one line it was missing and no counter: ObjectCalls.getMethodBind prints
"[kanama:kt] FAULT bind-lookup-failed Class.method hash=<hash>" on System.err. There is
no shim on desktop and a null bind crashes at the call, which is already loud; what was
missing was WHICH bind, at the lookup, after a Godot bump.

Item 36 rides along: kanama_ios_godot_tweener_set_trans, _tweener_set_ease and
_property_tweener_from_color are deleted with their three g_*_bind statics, their three
hash constants and their header declarations — nothing under src/ referenced them.
kanama_ios_godot_set_first_node_in_group_text and _node2d_set_position both DO have
Kotlin callers (KanamaIosRuntime.kt:605, IosGodotApi.kt:903) and are kept and ruled.

scripts/check_ios_shim_faults.py (new local_ci stage) re-derives every guarded early
return from the source and fails on the first that does not report, so the rule holds for
the next guard somebody adds. A stale BENIGN entry fails too.

Verified: check_ios_shim_faults PASS (131 entry points, 24 _dispatch bodies, 217 guarded
returns, 215 reporting, 2 benign) and its red run — one kanama_ios_fault( call removed on
a scratch copy — FAILs on exactly that line; check_ios_static_dispatch, check_wrapper_
generator, check_objectcalls_parity, check_pt_tag_tables, check_doc_claims all PASS;
clang -fsyntax-only -std=c11 -Wall -Wextra clean (same 7 pre-existing warnings as main);
ktfmtFormat no-op; compileKotlinJvm, compileKotlinIosArm64 and
createIosDeviceDebugXcframework BUILD SUCCESSFUL.

Assisted by AI

Co-Authored-By: Claude <noreply@anthropic.com>
…two expected probes

docs/exporting/ios.md gains "When You See A FAULT Line": what the line means (a call
that did not reach Godot and returned its default), the full table of the ten reason
tokens, the bracketed fault-probe window a healthy DEBUG build prints on purpose, the
faults=2 expected=2 contract on both self-test summary lines, and that the device runner
fails the run on any FAULT line outside that window.

docs/contributing/backends/ios.md gains "Contract: no silent paths (the fault sink)": the
sink and its two exported readers, the rule that every guarded early return in an exported
entry point or a _dispatch body reports, the gate that keeps the rule, the ten tokens, the
two documented benign returns, why bind lookups report at lookup time (and why desktop
mirrors the line without a counter), and the self-test's two expected probes with the
reason the runner trusts the printed window rather than the reason token.

CHANGELOG: Fixed/Added entries with the counts (131 exported entry points, 24 _dispatch
bodies, 217 guarded early returns, 215 reporting, 2 benign) and the three deleted item-36
tweener entry points; the task 117 P2' line that said those three "stay for a later
cleanup" now says task 124 deleted them.

docs/reference/generated/gates.md is regenerated for the new local_ci stage. The
regeneration also corrects a pre-existing staleness: check_ios_static_dispatch.py's "first
landed" date was 2026-09-21 in the committed page and 2026-09-22 from git, so
`generate_gates_index.py --check` was already failing on main before this change.

Verified: mkdocs build --strict, check_doc_claims OK, generate_gates_index --check PASS.

Assisted by AI

Co-Authored-By: Claude <noreply@anthropic.com>
…ll along

Review blocker B1. Five level-2 self-test rows asserted that a drained pending slot
answers -1 (utf8 x2, packed, container_blob, blob). That -1 is reachable ONLY through
the guard that task 124 taught to report `pending-protocol`, so those rows were red runs
of that guard in disguise: a healthy debug build printed `faults=7 expected=2` and five
FAULT lines OUTSIDE the probe window, and kanama-demos/scripts/ios_device_run.sh
correctly failed the run on the first of them. The mechanism was right; the rows were
mislabelled.

So they now say what they are. All seven deliberate faults live in one place,
runFaultProbes, inside the single `fault-probes begin/end` window, and each of the five
moved rows asserts three things instead of one: the old `== -1L`, that faultCount() rose
by exactly one, and that lastFault() names both `pending-protocol` and the buffer. Their
producers did not move, and neither did the non-default assertions that proved those
producers worked (`get_utf8_string 3000B==put, pending slot`, `Crypto.generate_random_
bytes(3000).size==3000, pending slot`, and so on): what each row proved about its own
producer is still proved in its own place, with a comment there pointing at the probe.
SELFTEST_EXPECTED_FAULTS is 7 and is declared immediately above runFaultProbes with a
comment listing the seven (review N1) — the constant and the things it counts are now one
screen apart instead of three thousand lines.

Audit of every take_pending_* call in the self-test (B1.4): the five moved rows were the
only ones that could hit a guard on a healthy run. The other eight calls all sit in the
`else` branch the producer's own over-capacity length selected, so they take only after
the C side parked something. There is exactly one `fault-probes begin` and one `end`, and
the frame-1 phase raises no faults of its own — it reprints the same never-reset total.

S1: scripts/check_ios_shim_faults.py accepted any `kanama_ios_fault(` substring anywhere
in the guard's block. It now requires the call to be on the guard's own path and
textually BEFORE the first return at that depth, read from the comment-stripped source.
A call after the return is dead code; one nested in `if (0) { ... }` may never run; both
are now findings with their own message. The unmodified shim still reports 215/217 under
the new rule.

S2: kanama-demos/scripts/ios_device_run.sh trusted the printed probe window without
checking it. An unterminated window (`begin` with no `end`) would have swallowed every
FAULT line after it — a crash mid-probe would disable exactly the check meant to catch
it — and a run that opened a window but printed no summary line never reached the
faults=/expected= comparison at all. Both now FAIL with a message naming the condition.

S3: two degenerate expressions in the shim. `get_first_node_in_group == NULL ? NULL :
NULL` becomes two guards naming the bind that is actually missing; the packed take's
`!g_pending_packed_valid ? "g_pending_packed" : "g_pending_packed"` splits into
"g_pending_packed (nothing pending)" and "g_pending_packed (kind mismatch)", which are
two different protocol violations reaching the same -1.

N3: Godot calls into the shim from worker and physics threads, so g_fault_count is an
_Atomic int32_t bumped with atomic_fetch_add and SATURATING at INT32_MAX. It must never
wrap: a wrapped count reads as a small number and `faults=<small> expected=7` would pass
a run that faulted two billion times, which is the silence this task exists to remove.
g_last_fault stays a plain buffer, and both the shim comment and ios/include/kanama_ios.h
now say that kanama_ios_last_fault() is a best-effort snapshot that may tear under
concurrent faults — the count is the contract, the text is the hint.

N4 is recorded, not fixed: docs/contributing/backends/ios.md now states the contract's
scope precisely (exported entries, `_dispatch` bodies, and the static helpers directly
behind exported entries) and that switch-default returns in other static helpers —
kanama_ios_packed_kind:7993, kanama_ios_get_virtual_call_data:9654,
kanama_ios_construct_extension_object:9671, kanama_ios_extension_create_instance:9844 —
are covered by their in-scope callers' reports.

Docs and CHANGELOG (S4/N2): every `faults=2 expected=2` becomes `faults=7 expected=7`
with the seven named, docs/exporting/ios.md's "both numbers are 0" is corrected (release
builds run no self-test and print neither summary line), and the shim counts move from
217 guarded / 215 reporting to 219 / 217 — the S3 split added two guards.

Verified: check_ios_shim_faults PASS (131 entry points, 24 _dispatch bodies, 219 guarded
returns, 217 reporting, 2 benign) plus both new red runs (sink after the return, sink in
`if (0)`) FAILing on exactly that guard; check_ios_static_dispatch, check_wrapper_
generator, check_objectcalls_parity, check_pt_tag_tables, check_doc_claims PASS;
generate_gates_index --check PASS after regeneration; clang -fsyntax-only -std=c11 -Wall
-Wextra clean (same 7 pre-existing warnings as main); mkdocs build --strict clean;
ktfmtFormat, compileKotlinJvm, compileKotlinIosArm64 and createIosDeviceDebugXcframework
all BUILD SUCCESSFUL. The three demos console runs are in the paired demos commit.

Assisted by AI

Co-Authored-By: Claude <noreply@anthropic.com>
…l lookup is benign and stops polling

The first real device run (iPhone 12, Match3, c131e5f + demos a9628e1) showed two things.

1. The probe window cannot work on a device. The self-test's fault-probes begin/end markers
   are Kotlin println (stdout), the sink's lines are fprintf(stderr), and devicectl --console
   merges the two streams without preserving relative order: all seven probe FAULT lines landed
   after the end marker and the runner failed a healthy build. The sink now marks the probes
   itself: new exported kanama_ios_fault_set_probe(int32_t) (declared in kanama_ios.h). While on,
   kanama_ios_fault still counts and records g_last_fault under the same print budget, but prints
   `[kanama][ios][c] FAULT-PROBE <entry>: <reason> <detail>`. runFaultProbes turns it on before the
   seven probes and off after them in a try/finally. The begin/end println lines stay as
   human-readable markers only; nothing parses them.

2. set_first_node_in_group_text was misclassified and its caller polled forever. Its
   `label == NULL` branch is the lookup's answer "no node is in group kanama_ios_probe" (only the
   example project has that label), not a bad argument. It is now the third documented BENIGN
   silent return (shim comment + check_ios_shim_faults.py BENIGN entry); the function's other
   guards still report. KanamaIosRuntime.frame() retried the lookup on every frame for the
   game's whole life; it now gives up at frame 120, logs
   `probe label group absent after 120 frames; not retrying` once, and later frames return right
   after MainThread.pumpNextFrame(). The example project's label is a static node in its main
   scene and is still found on frame 1.

Gate totals: 132 exported entry points, 24 _dispatch bodies; 219 guarded early returns across 83
functions, 216 reporting, 3 documented benign. Docs (exporting/ios.md, contributing/backends/ios.md)
and CHANGELOG describe FAULT-PROBE vs FAULT, the sink's probe mode, the three benign returns, and
why ordering across streams cannot be relied on; the probe-window wording is gone.

Assisted by AI
Co-Authored-By: Claude <noreply@anthropic.com>
@falcon4ever
falcon4ever marked this pull request as ready for review October 1, 2026 20:11
@falcon4ever
falcon4ever merged commit a782a07 into main Oct 1, 2026
10 checks passed
@falcon4ever
falcon4ever deleted the task-124/no-silent-paths branch October 1, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant