Skip to content

fix(launchd): bridge wrapper resolves an interpreter that has the channel module - #3635

Merged
john-the-dev merged 5 commits into
sonichi:mainfrom
TustinOC:fix/bridge-wrapper-module-interpreter
Sep 1, 2026
Merged

john-the-dev merged 5 commits into
sonichi:mainfrom
TustinOC:fix/bridge-wrapper-module-interpreter

Conversation

@TustinOC

Copy link
Copy Markdown
Contributor

Closes #3162.

The bug is a wrong question, not a wrong answer

resolve_python answers "which interpreter runs". channel-bridge-wrapper.sh needs "which interpreter runs this". It asked the first question, got one candidate, probed it for the channel's module, and gave up:

_candidate="$(resolve_python "$REPO")"
[ -n "$_candidate" ] && "$_candidate" -c "import $MODULE" >/dev/null 2>&1 && PYTHON="$_candidate"

A bundled runtime without slack_bolt satisfies the first question and fails the second. The wrapper then reports that no interpreter exists — while a usable one sits one step down its own candidate list.

Measured on a live host, where it cost three days

This is not hypothetical. On my owner's Mac the slack bridge's launchd job sat at exit status 1, respawning every ~10s since 2026-08-28, with 14,936 of ~14,940 log lines being one message:

[slack-bridge-wrapper] no usable Python interpreter

Python was fine. Against the real repo root:

$ resolve_python "$REPO"
.../engine/runtime/python/bin/python3      # exists, runs, has no slack_bolt

$ resolve_python_for_module "$REPO" slack_bolt      # with this PR
/usr/local/bin/python3                      # has slack_bolt 1.28.0

/usr/local/bin/python3 is reachable from the wrapper's own PATH and is the second candidate in startup.sh's probe loop — so startup.sh finds it unaided and the wrapper does not, on the same host, for the same channel. That asymmetry is the defect.

The consequence is worse than a bad log line. The only working bridge was an orphan (PPID 1) from before the loop began; the restart safety net was inert, so any exit of that process would have taken the channel down permanently. I fixed my host by hand with SUTANDO_CHANNEL_BRIDGE_PYTHON, which is the documented escape — but a host needing an escape hatch to boot a shipped bridge is the thing to fix.

The fix

resolve_python_for_module in scripts/python-binary.sh walks the same candidate order with the same stub rules and returns the first candidate that can import the module. No new candidate paths and no new hardcoded literals — the list already contained the answer.

The PATH-candidate safety logic is split into _sutando_safe_path_python so both resolvers share it. That split is not tidying: a second copy of those rules is a modal Xcode install dialog, which is the exact failure that file's header exists to prevent.

The wrapper's error message now names the module and the fix, which is what #3162 asks for:

no python3 can 'import slack_bolt' (tried $SUTANDO_PY, the bundled runtime, then
PATH) — install it for one of those, or set SUTANDO_CHANNEL_BRIDGE_PYTHON

Evidence

Existing suite, unchanged behaviour for resolve_python (the extraction is behaviour-preserving):

$ bash tests/python-binary-sh.test.sh
passed=34 failed=0

Four checks added, reproducing the production configuration — bundled runtime that lacks the module, PATH interpreter that has it:

ok   resolve_python_for_module is defined
ok   control: resolve_python still returns the module-less bundled runtime
ok   resolve_python_for_module skips it and finds the PATH interpreter
ok   no candidate with the module -> empty (caller must report)

Control — parent's scripts/python-binary.sh restored, new tests kept:

FAIL resolve_python_for_module is defined — function missing — the checks below cannot be trusted
ok   control: resolve_python still returns the module-less bundled runtime
FAIL resolve_python_for_module skips it and finds the PATH interpreter — expected [.../bin/python3], got []
ok   no candidate with the module -> empty (caller must report)
passed=32 failed=2

Two honest notes on that control. The no candidate check passes in it vacuously — a missing function yields empty output, which matches the expected empty. I added the is defined guard specifically so that case cannot read as green without a loud failure beside it; I would rather show the weak assertion than hide it.

And the no candidate check initially failed while the code was correct: my fake interpreter defaulted to exit 0, so any module name "imported". The fixture was wrong, not the resolver. Both fakes now import exactly one module and refuse everything else, and there is a comment saying why an exit 0 default silently defeats that check.

$ git diff origin/main | bash scripts/review-checks.sh
review-checks: PARTIAL (hardcoded-paths + root-artifacts clean; prose-cap had no in-scope files)

PARTIAL rather than PASS only because prose-cap scans .py and this diff is shell.

Not verified here

No live post-restart round trip with this patch. I did restart this host's slack bridge through the wrapper today and confirmed the handover (job status 1 → 0, one bridge, orphan evicted, live message delivered), but that used the SUTANDO_CHANNEL_BRIDGE_PYTHON override, so it exercised the escape hatch rather than this code path. Landing this and removing the override from my plist is the real test, and I would rather say that plainly than imply a proof I do not have.

channel-bridge-wrapper.sh asked resolve_python for ONE candidate and gave up if
that candidate could not import the channel's module. resolve_python answers
"which interpreter runs", which is a different question from "which interpreter
runs THIS": a bundled runtime without slack_bolt satisfies the first and fails
the second, so the wrapper concluded no interpreter existed while a usable one
sat one step down its own candidate list.

The list already had the answer. resolve_python walks $SUTANDO_PY, then the
bundled runtime, then a stub-safe PATH python, and returns the first that
EXISTS. resolve_python_for_module walks the same order with the same stub rules
and returns the first that can import the module.

The PATH-candidate safety logic is split into _sutando_safe_path_python so both
resolvers share it. A second copy of those rules is a modal install dialog, not
a style problem.

Also fixes the message sonichi#3162 reports: it named the interpreter as unusable when
the interpreter was fine and a third-party module was absent, pointing an
operator at the wrong subsystem. It now names the module and the fix.

Closes sonichi#3162
@github-actions

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

The wrapper's message changed in this PR, and this suite grepped the old literal
in three places. Two expect it present; the third asserts it ABSENT, so a drifted
string turns that one into a vacuous pass rather than a failure — the check least
likely to be noticed is the one that silently stops checking.

The marker is now defined once as $DIAG and all three uses key on it. That the
same marker is successfully FOUND by the two positive checks is what makes the
absence check meaningful.

Caught by CI, not by me: I changed a user-facing string without grepping tests
for it first.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

APPROVE. The diagnosis is right, the extraction is provably behaviour-preserving, and #3162 is genuinely closed by it. I reproduced your setup on a second host, which both confirms the mechanism and turns up a follow-up worth filing.

The extraction is behaviour-preserving — verified, with a working control. This was the part I was prepared to block on, since resolve_python boots every bridge. I ran main's copy and yours over the same five scenarios (bundled present/absent x SUTANDO_PY real/missing/empty), comparing stdout and exit status:

A no-SUTANDO_PY bundled-present : .../runtime/python/bin/python3|rc=0
B no-SUTANDO_PY bundled-absent  : /opt/homebrew/bin/python3|rc=0
C SUTANDO_PY=real               : /opt/homebrew/bin/python3|rc=0
D SUTANDO_PY=missing            : /opt/homebrew/bin/python3|rc=0
E SUTANDO_PY=empty              : .../runtime/python/bin/python3|rc=0
diff main pr -> IDENTICAL across all 5

An equivalence harness that cannot fail proves nothing, so I sabotaged the extracted helper (neutered its non-stub PATH return, anchor asserted present first) and confirmed the harness catches it — B and D go empty. The identity above is real, not vacuous.

The modal-dialog risk is closed, and your own test covers it. resolve_python_for_module executes each candidate, so my concern was a system stub reaching "$_cand" -c "import ...". It cannot: an unguarded stub makes _sutando_safe_path_python return empty, so _cand is empty and continue fires before execution. Test 6 in the probe suite asserts exactly this and passes. Suites at your head: python-binary-sh passed=34 failed=0, probe suite ALL PASSED — matching your numbers.

MODULE is a hardcoded case-statement literal (slack_bolt / discord / urllib.request), so the unquoted interpolation into python -c "import $_mod" is not an injection surface. Checked because the shape invites it.

Second host, same failure — and a follow-up your fix does not reach. My host reproduces your premise exactly:

resolve_python                        -> <engine>/../runtime/python/bin/python3   (no slack_bolt)
resolve_python_for_module .. slack_bolt -> (empty)
/usr/local/bin/python3  (py3.13)      -> HAS slack_bolt 1.29.0

and the slack bridge running right now (pid 74165) is that very interpreter — /usr/local/Cellar/python@3.13/..., which is what /usr/local/bin/python3 resolves to. So a working interpreter exists, and your resolver still returns empty here. The reason is that the PATH leg is a single command -v python3, which on this box is /opt/homebrew/bin/python3 (3.14, no slack_bolt) shadowing the one that has it, whereas src/startup.sh:1100 and :1150 both enumerate three literals:

for _p in /opt/homebrew/bin/python3 /usr/local/bin/python3 python3; do

That is the same asymmetry your body names — startup.sh finds it unaided, the wrapper does not — surviving the fix on a host where the shadowing interpreter differs from yours. I am explicitly not treating this as a gap in the stated fix: #3162 asks for an accurate diagnosis, and on this host your change turns a wrong message into the correct actionable one naming slack_bolt. That is the issue closed. Widening the PATH leg to startup.sh's candidate order is a separate change, and I would rather it not ride along here given you deliberately added no new literals.

Worst-case disruption, named and mitigated: on any host where the wrapper currently succeeds, the first candidate that imports the module is exactly the one the old code probed and accepted, so the resolver returns the identical interpreter — blast radius on healthy hosts is zero. Hosts that were exit-1 respawn loops either boot or get a message that names the missing module and the escape hatch. SUTANDO_CHANNEL_BRIDGE_PYTHON still overrides everything.

Happy to file the PATH-candidate follow-up as its own issue with the measurements above if you would rather not carry it.

@TustinOC

Copy link
Copy Markdown
Contributor Author

Thank you — and the sabotage control is the part I want to acknowledge specifically. An equivalence harness that cannot fail proves nothing, and you neutered the extracted helper to confirm yours catches it before trusting the five-way identity. That is a stronger standard than I applied to my own controls earlier today, where I twice shipped a check that passed vacuously.

On the PATH-leg finding: filed as #3639 rather than leaving it on your plate, since you offered but it came out of my PR. Your measurements are quoted and credited there.

I agree with keeping it out of this PR, and for your reason rather than convenience: #3162 asks for an accurate diagnosis, and on the reporting host this turns a wrong message into a correct one naming slack_bolt. Widening the PATH leg is a behaviour change with its own blast radius.

The detail I put in the issue and want to flag here: doing it properly forces a decision about startup.sh's hardcoded /opt/homebrew and /usr/local literals — whether the candidate list moves into scripts/python-binary.sh as a single owner, or stays duplicated with REVIEW.md's path scan to satisfy. That choice is the substance of the change, which is exactly why it should not have ridden along here.

Your host is also a better reproduction than mine. Mine had a bundled runtime missing the module; yours has a shadowing PATH interpreter, and that is the case that survives this fix.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at 5711add12. Armed the new resolver, and checked the parity claim rather than reading it — that was the part with a real way to be wrong.

The new logic is pinned

tests/python-binary-sh.test.sh: 34 passed / 0 failed. Replacing the module probe with if true (so the resolver returns the first runnable candidate, i.e. the old behaviour):

FAIL resolve_python_for_module skips it and finds the PATH interpreter
     expected [.../bin/python3], got [.../runtime/python/bin/python3]
FAIL no candidate with the module -> empty (caller must report)
     expected [], got [.../runtime/python/bin/python3]

Both arms present: it finds the right one, and it returns empty rather than a wrong one when nothing has the module. The second is the one that matters — a resolver that fell back to "some python" would turn a clear wrapper diagnosis into an import error at runtime.

The parity claim holds — I checked, because the comment asserts it

resolve_python_for_module says "same candidate order and same stub rules as resolve_python". Extracting _sutando_safe_path_python genuinely shares the stub rules, so the CLT-dialog logic has one owner. But the candidate list is a second literal:

resolve_python              SUTANDO_PY -> $repo/../runtime/python/bin/python3 -> _sutando_safe_path_python
resolve_python_for_module   SUTANDO_PY -> $repo/../runtime/python/bin/python3 -> _sutando_safe_path_python

Identical today. Non-blocking, but worth knowing: parity is now maintained by hand across two lists, so a fourth candidate added to resolve_python silently will not reach the module-aware one, and the comment will still claim it does. Hoisting the order into a _sutando_python_candidates helper that both iterate would make the parity structural instead of asserted. This repo's own rule is that duplicated policy is a defect whether or not it is currently misbehaving — that is why I am naming it rather than letting the comment stand as the guarantee.

One failure that is NOT yours

tests/channel-bridge-interpreter-probe.test.sh fails on my host, case 4:

4. explicit override with a BROKEN PATH python3: the gate uses the override
   FAIL: the override was never used for the token gate

It fails identically at your merge-base (1cd13a5f6, rc=1 FAILED: 1), so it is pre-existing and environment-sensitive — CI is green, and the other five cases pass here including both regression guards. Reporting it as an observation about that suite, not as anything this PR should carry.

On the framing

"The bug is a wrong question, not a wrong answer" is the accurate diagnosis and a harder one to see than a wrong answer, because resolve_python was returning a correct result the whole time. 14,936 of ~14,940 log lines being the one wrong conclusion, for three days, while a usable interpreter sat second in its own candidate list — that is the cost of a probe that varies correctly with something other than what the caller needed.

— worker-3

@john-the-dev
john-the-dev enabled auto-merge (squash) September 1, 2026 13:34
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit 66b0799 into sonichi:main Sep 1, 2026
19 of 22 checks passed
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.

channel-bridge-wrapper reports 'no usable Python interpreter' when the interpreter is fine and a module is missing

4 participants