# 06 python-php Date: 2026-09-06. Repo commit: `ab93833` (working tree, phase 6 changes uncommitted on top). Host Docker: 29.6.2. Images: `interop-python:2.2.4-3.12.14` (`python:3.12.14-slim-bookworm` + `pip install smpplib==2.2.4`), `interop-php:8.4.25-1d3b53c` (php-smpp, `alexandr-mironov/php-smpp`, cloned at commit `1d3b53c2d2b63d51ab70009d956914b7f8903118`, built on `php:8.4.25-cli`), `nicolaka/netshoot:v0.16` (capture sidecar), `node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`). ## Setup Each peer is a small driver (`interop-tests/peers//driver.{py,php}`) exposing an HTTP command channel the node test file drives, the same shape as the Java drivers in phase 5 - one long-lived process per peer holding named client sessions open across requests. - **python**: `driver.py` runs a `ThreadingHTTPServer`; each named session is a `smpplib.client.Client` plus a background thread calling `read_once()` in a loop once `/startReader` is called. A plain `submit_sm` is answered by `@larvit/smpp` only once the application calls `sms.sendResp()` (README, Server), so `/submit` sends and returns the sequence number immediately rather than blocking for the ack - a synchronous wait here deadlocks against the node test's own "wait for the sms, then answer it" flow. `/ack?name=&sequence=` polls the result afterwards. A multipart submit is answered automatically by the library on arrival, so `/submitLong` keeps its per-part `wait_ack()`, unaffected by the same deadlock. - **php**: `driver.php` is a hand-rolled single-connection-at-a-time HTTP server (no framework), since php-smpp itself is fully synchronous - every send blocks reading its own response on the same call. This means a plain submit deadlocks against `sendResp()` exactly as above, but there is no background thread to poll afterwards, so the fix is the opposite one: the node test's global `session.on('sms', ...)` handler calls `sendResp()` immediately for every arrived sms (the same pattern `kannel.test.ts` uses for its "maxp1" burst, generalised here to the whole file). A `deliver_sm` built by hand from the node side and read via `/receive` (php's blocking `readSMS()`) has the same shape in reverse: the `/receive` call has to be in flight before the `deliver_sm` goes out, or `session.send()`'s own wait for `deliver_sm_resp` has nothing to unblock it yet. Build snags, fixed in the Dockerfile, not in `src/`: - **PHP 8's `sockets` extension returns a `Socket` object from `socket_create()`, not a resource.** This fork's `Socket::isOpen()` (written pre-PHP8) checks `is_resource($this->socket)` alone, so it is always `false` on PHP 8.x, and every guarded call - `bindReceiver()`, `bindTransmitter()`, `bindTransceiver()`, `close()`, `sendCommand()` - throws `SocketTransportException('Socket is not open')` immediately, regardless of the actual connection. The whole library is unusable on PHP 8+ without this. Patched with a build-time `sed` on `src/transport/Socket.php` (also accept `$this->socket instanceof \Socket`), not in the vendored source itself. - `mbstring` needs `libonig-dev` on this base image; the official image warns "mbstring is already loaded" (it is bundled but not built as a shared extension), harmless. Two runs each of `./interop-tests/run.py python` and `./interop-tests/run.py php`, all four stable: python 13/13 both times (frames 449/450, `enquire_link` ~185, `submit_sm`/`submit_sm_resp` 24/24, `deliver_sm`/`_resp` 3/3, malformed 0, expert errors 0); php 9/9 both times (frames 42, `bind_transmitter`/`bind_receiver`/`bind_transceiver` and their resps all matched, `submit_sm`/`_resp` 11/11, `deliver_sm`/`_resp` 1/1, malformed 0, expert errors 0). ## Scenarios (PLAN.md) | Id | Result | Evidence | | --- | --- | --- | | S11 GSM 03.38 basic table + extension table (python) | pass, one peer-side quirk | `python.test.ts` "GSM 03.38 basic table + extension table round trip": whole 127-char table (minus the escape character itself) plus `€[]{}\|~^` round-trips exactly | | S11 form feed (python) | pass, one peer-side quirk | `python.test.ts` "form feed (0x1B 0x0A)...": raw `1b 0a` appended after `gsm_encode()`'s own bytes decodes to `\f` | | S11 Latin-1 (python) | pass | `python.test.ts` "Latin-1 round trip" | | S11 UCS-2 with 一 and an emoji (python) | pass | `python.test.ts` "UCS-2 with 一 and an emoji round trip" | | S11 reverse direction, GSM and UCS-2 (python) | pass | `python.test.ts` "reverse direction: server sends ... text back, python decodes it the same way" (both) | | S11 the 0x5F quirk, both ways | pass (documents the peer's own table, not asserted as our defect) | `python.test.ts` "peer quirk, documented both ways: byte 0x5F..." | | S2 UDH 2/3/10 segments (python) | pass | `python.test.ts` "S2 - long messages", one `sms` per size, `answeredOnArrival` true, ids `-1..N` | | S2 `CSMS_16BIT_TAGS`, `CSMS_PAYLOAD`, `CSMS_8BIT_UDH` (php) | pass, all three | `php.test.ts` "S2 - long messages (php-smpp, three CSMS spellings)" | | S4 separate TX/RX binds (php) | pass | `php.test.ts` "S4 - bind direction": `boundAs`/`bindAllows()` both ways, `submit_sm` on RX → `ESME_RINVBNDSTS` (status 4) and the peer keeps working, `submit_sm` on TX unaffected, `deliver_sm` reaches RX only (TX's own `/receive` times out) | | Keepalive, reactive `enquire_link` (python) | pass | `python.test.ts` "Keepalive...": silent past 40s idleTimeout → session closes; `auto_send_enquire_link` with a 10s client timeout → survives the same 45s | | Refusal via `onRequest` (python, php) | pass, both peers | `python.test.ts`/`php.test.ts` "Refusals via onRequest": `ESME_RTHROTTLED` surfaced as the ack status (python) or a caught `SmppException` code (php); `enquire_link` and a follow-up submit both still work | ## Defects in @larvit/smpp None found. Every encoding, both directions, every long-message spelling from both peers, the bind-direction enforcement, the idle/keepalive behaviour, and the `onRequest` refusal path all matched the README and the spec. ## Peer quirks - **python-smpplib's `gsm.GSM_CHARACTER_TABLE` disagrees with GSM 03.38 (and this library) at one code point: 0x5F.** The real table's value there is SECTION SIGN (§); smpplib's own table (its `gsm.py` docstring already calls it "vendor-specific and not recommended for use") has a backtick instead. Encoding a literal backtick through `gsm.gsm_encode()` sends byte `0x5F`, which this library correctly decodes to §; sending §'s own byte back and decoding it through smpplib's table (as the driver's `gsm_decode()` deliberately does, to compare like for like) reads a backtick, not a §. Both directions reproduced in `python.test.ts`'s "peer quirk" test. Confirmed by diffing `smpplib.gsm.GSM_CHARACTER_TABLE[:128]` against this library's own `gsmChars` table (identical at every other of the 128 positions). - **`gsm.gsm_encode()` cannot produce a form feed at all.** The character table represents that extension-table slot with a placeholder backtick, not the literal `\x0c` character, so `GSM_CHARACTER_TABLE.index('\x0c')` raises `ValueError` (wrapped as `UnicodeError`) for any attempt to encode one. The driver builds the `1b 0a` byte pair by hand for that one character (see `python.test.ts`'s "form feed" test) rather than through the library's own encoder. - **`auto_send_enquire_link` defaults to `True`**, on `read_once()`/`poll()`/`listen()` - not opt-in, correcting `research/esme-clients-and-validators.md`'s A4 note. The driver passes it explicitly either way so both keepalive scenarios are deliberate. - **php-smpp's `GsmEncoderHelper::utf8_to_gsm0338()` has no dictionary entry for ¤ (CURRENCY SIGN, U+00A4, GSM 03.38 code `0x24`).** `strtr()` only rewrites characters present in its dict; ¤ passes through as its raw two-byte UTF-8 sequence (`c2 a4`), which this library's GSM decoder (correctly, since neither byte is in the 128-entry table) renders as two spaces. Reproduced manually (not asserted in `php.test.ts`, to keep that suite's own assertions unambiguous): submitting `"price:¤100"` at `data_coding` 0 arrives as `"price: 100"`. - **This php-smpp fork's `bindTransceiver()` actually works**, against both the plan's premise and the fork's own inherited README ("You can't connect as a transceiver, otherwise supported by SMPP v.3.4" - upstream OnlineCity text, unchanged by this fork despite `Client.php` plainly implementing `bindTransceiver()`). Confirmed directly: `php.test.ts`'s "Peer quirk: bindTransceiver() actually works" binds one against `server()` and gets `boundAs === 'transceiver'`. `php.test.ts`'s S4 scenarios still use separate TX/RX binds deliberately, since that is the shape target 8 needs regardless of whether TRX also happens to work. - **This fork's `composer.json` declares `"license": "LGPL-2.0-or-later"`**, though no top-level `LICENSE` file exists in the repo - correcting the plan's "no licence declared" for this specific fork (true of the field, not true of the declaration). Still test-only per the phase brief; not vendored into this repo either way. - **`submit_sm()`'s returned message id carries a trailing NUL byte** (`unpack("a*msgid", ...)` keeps it, unlike PHP's `A`-format unpack which would trim it) - cosmetic, not asserted against in `php.test.ts`. ## Open questions - Whether php-smpp's other `is_resource()`-adjacent assumptions (none found beyond `isOpen()` in this version) would surface on a longer-running session than these scenarios exercise. - Whether smpplib's `0x5F` table quirk affects any other vendor beyond this one - not checked against a second Python client, since none of comparable maturity was in scope for this phase.