Repository navigation
Task 124: the iOS bridge has no silent paths — every guarded early return reports - #276
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.cnever returns quietly: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(); KotlinObjectCalls.faultCount()/lastFault()._dispatchbodies), 217 reporting, 2 documented benign (
is_instance_id_valid(0); an empty Callable's null target), plus 47 more in thestatic helpers behind exported entries and lookup-time
bind-lookup-failed Class.method hash=…reports inget_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.scripts/check_ios_shim_faults.py(local_ci stage, gates index): fails when a guarded early return in an exported or_dispatchfunction does not call the sink (red run in the commit).fault-probes begin/endwindow — a wrong-hash lookup(
bind-lookup-failed), a null-bind ptrcall (null-bind), and the five pre-existing drained-pending-slot rows, which the reviewshowed were red runs of the
pending-protocolguard all along (faults=7 expected=2would have failed every debug run); bothsummary lines print
faults=F expected=E(7/7 on debug builds; release builds print neither line).getMethodBindlogs[kanama:kt] FAULT bind-lookup-failed …on a NULL lookup.faults != expected.Rulings recorded in the task spec: probe window instead of text whitelisting; second benign case;
encode-failedtoken; the threedocumented 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.main(cut from 27b8b17).its exact return value and signature; three dead exported functions removed (header + definitions); no change to any dispatch
body's success path. iOS
ObjectCallsgains two accessors and four self-test rows.docs/exporting/ios.md"When you see a FAULT line",docs/contributing/backends/ios.mdsink contract andprobes, 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-onlyno new warnings;compileKotlinJvm,compileKotlinIosArm64,createIosDeviceDebugXcframeworkPASS.Red runs: gate (one sink call removed → FAIL), runner (extra FAULT line → FAIL;
faults=3 expected=2→ FAIL; 2/2 → PASS) — therunner 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=7on both summary lines andexactly 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