Run the load generators against our server: the window holds, memory returns to baseline, no defects
This commit is contained in:
@@ -89,7 +89,7 @@ coverage.
|
|||||||
| **jsmpp** — Java, active | Strict, low-level: the caller builds UDH, `sar_*` or `message_payload` bytes by hand, so it is the tool for targets 2, 3, 5 and 6. Assumes 3.4 when `sc_interface_version` is absent; crashes on an unnameable one | Maven build in a pinned JDK image; no published image | `interface_version`, raw optional parameters, `query_sm`/`cancel_sm`/`replace_sm` calls |
|
| **jsmpp** — Java, active | Strict, low-level: the caller builds UDH, `sar_*` or `message_payload` bytes by hand, so it is the tool for targets 2, 3, 5 and 6. Assumes 3.4 when `sc_interface_version` is absent; crashes on an unnameable one | Maven build in a pinned JDK image; no published image | `interface_version`, raw optional parameters, `query_sm`/`cancel_sm`/`replace_sm` calls |
|
||||||
| **Cloudhopper (fizzed fork)** — Java/Netty | The windowing client: `setWindowSize`, `setRequestExpiryTimeout`, `setWindowMonitorInterval`; async submits; an SSL demo | Maven build; `make client`, `make ssl-client` | Window 1, 10, 50 against a slow `sms` listener; request expiry shorter than our response |
|
| **Cloudhopper (fizzed fork)** — Java/Netty | The windowing client: `setWindowSize`, `setRequestExpiryTimeout`, `setWindowMonitorInterval`; async submits; an SSL demo | Maven build; `make client`, `make ssl-client` | Window 1, 10, 50 against a slow `sms` listener; request expiry shorter than our response |
|
||||||
| **python-smpplib 2.2.4** — Python | Sends GSM 7-bit unpacked with `0x1B` escapes, UDH long messages, reactive `enquire_link`; the easiest peer to script, so also the driver for the server-side matrix | `python:3.12.14-slim-bookworm` + `pip install smpplib==2.2.4` | `interface_version` kwarg, `auto_send_enquire_link`, `make_parts_encoded` |
|
| **python-smpplib 2.2.4** — Python | Sends GSM 7-bit unpacked with `0x1B` escapes, UDH long messages, reactive `enquire_link`; the easiest peer to script, so also the driver for the server-side matrix | `python:3.12.14-slim-bookworm` + `pip install smpplib==2.2.4` | `interface_version` kwarg, `auto_send_enquire_link`, `make_parts_encoded` |
|
||||||
| **php-smpp (alexandr-mironov fork)** — PHP | Cannot bind transceiver, so it forces separate TX and RX binds — the direction-enforcement path; three long-message modes from one client: `CSMS_16BIT_TAGS`, `CSMS_PAYLOAD`, `CSMS_8BIT_UDH` | `php:8.x-cli`, vendored; no licence declared, so test-only | The three CSMS modes; `submit_sm` on the RX bind |
|
| **php-smpp (alexandr-mironov fork)** — PHP | Binds transmitter and receiver separately, which is the direction-enforcement path; three long-message modes from one client: `CSMS_16BIT_TAGS`, `CSMS_PAYLOAD`, `CSMS_8BIT_UDH`. It does bind as a transceiver, contrary to what its inherited README says — phase 6 found that out | `php:8.4.25-cli`, cloned at a pinned commit; LGPL-2.0-or-later per its `composer.json`. Its socket guard uses a check PHP 8 broke, so the peer image patches it | The three CSMS modes; `submit_sm` on the RX bind |
|
||||||
| **Jasmin `smppccm`** — Python/Twisted | A production gateway as the ESME: its own reconnect, `elink_interval`, receipt parsing of what we send, `dlr_msgid` | Same stack as above; `smppccm -a` pointing at our server, `mtrouter`, HTTP `/send` | `elink_interval`, `con_loss_retry`/`con_loss_delay`, `coding`, `dlr_msgid`, `submit_throughput` |
|
| **Jasmin `smppccm`** — Python/Twisted | A production gateway as the ESME: its own reconnect, `elink_interval`, receipt parsing of what we send, `dlr_msgid` | Same stack as above; `smppccm -a` pointing at our server, `mtrouter`, HTTP `/send` | `elink_interval`, `con_loss_retry`/`con_loss_delay`, `coding`, `dlr_msgid`, `submit_throughput` |
|
||||||
| **smppload 2.5.3** — Erlang | The only free, scriptable load generator with UDH long messages: `-r` rps, `-T` threads, `-c` count, `-D` receipts, `-C` data_coding | Hand-built OTP image; expect build friction (issue #8) | No real window — in-flight is `threads × rps` — and no `enquire_link` at all, which is itself a real-world shape |
|
| **smppload 2.5.3** — Erlang | The only free, scriptable load generator with UDH long messages: `-r` rps, `-T` threads, `-c` count, `-D` receipts, `-C` data_coding | Hand-built OTP image; expect build friction (issue #8) | No real window — in-flight is `threads × rps` — and no `enquire_link` at all, which is itself a real-world shape |
|
||||||
| **vponomarev/libsmpp `smpp-dumb-client`** — Go | The one load tool with an enforced bounded window (`generator.window`) | Go build stage, YAML config | `rate`, `window`, `count`, `stayConnected` |
|
| **vponomarev/libsmpp `smpp-dumb-client`** — Go | The one load tool with an enforced bounded window (`generator.window`) | Go build stage, YAML config | `rate`, `window`, `count`, `stayConnected` |
|
||||||
@@ -200,6 +200,7 @@ newer changes stayed consistent with them.
|
|||||||
| `reconnect: { fromStart: true }` (#80) | `client()` resolves only once bound, and only the caller's signal ends the wait. Is that obvious enough that nobody ships a process that hangs at startup? |
|
| `reconnect: { fromStart: true }` (#80) | `client()` resolves only once bound, and only the caller's signal ends the wait. Is that obvious enough that nobody ships a process that hangs at startup? |
|
||||||
| A receipt's body read as octets (#81) | `dlr.receipt` now parses for peers where it used to be garbage. Anything an application could have been relying on in the broken case? |
|
| A receipt's body read as octets (#81) | `dlr.receipt` now parses for peers where it used to be garbage. Anything an application could have been relying on in the broken case? |
|
||||||
| The abortable send window (in flight) | What an aborted send returns, and whether it counts as `unanswered` — the field an application reads to decide whether resending risks a duplicate. |
|
| The abortable send window (in flight) | What an aborted send returns, and whether it counts as `unanswered` — the field an application reads to decide whether resending risks a duplicate. |
|
||||||
|
| The held-message cap against a peer's window (phase 7) | A peer whose window is wider than the 1000 messages this library will hold unanswered makes it evict, so the peer is never answered for those and re-sends or times out. The cap is deliberate and on a constant an application cannot raise, but it is the peer's window that decides whether it is ever reached. Does an application learn it is happening in time to slow down? |
|
||||||
| All of them together | `sessionError` now carries refused PDUs, lost reassembly groups and failed hooks. Is one channel carrying too many meanings for an application to act on any of them? |
|
| All of them together | `sessionError` now carries refused PDUs, lost reassembly groups and failed hooks. Is one channel carrying too many meanings for an application to act on any of them? |
|
||||||
|
|
||||||
## Delegation
|
## Delegation
|
||||||
@@ -242,7 +243,10 @@ defect; each is a claim resting on the specification and on Node rather than on
|
|||||||
| 3 | done | [03-jasmin.md](findings/03-jasmin.md) |
|
| 3 | done | [03-jasmin.md](findings/03-jasmin.md) |
|
||||||
| 4 | done | [04-kannel.md](findings/04-kannel.md) |
|
| 4 | done | [04-kannel.md](findings/04-kannel.md) |
|
||||||
| 5 | done | [05-java-clients.md](findings/05-java-clients.md) |
|
| 5 | done | [05-java-clients.md](findings/05-java-clients.md) |
|
||||||
| 6–11 | not started | — |
|
| 6 | done | [06-python-php.md](findings/06-python-php.md) |
|
||||||
|
| 7 | done | [07-load.md](findings/07-load.md) |
|
||||||
|
| 8 | dropped | [Untested](#untested) |
|
||||||
|
| 9–11 | not started | — |
|
||||||
|
|
||||||
Phases 1 to 5 were graded before `run.py` learned to fail an empty capture, so a run whose capture
|
Phases 1 to 5 were graded before `run.py` learned to fail an empty capture, so a run whose capture
|
||||||
never started would have scored green on the wire checks while its own assertions carried it. Every
|
never started would have scored green on the wire checks while its own assertions carried it. Every
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
x-dumbclient-healthcheck: &dumbclient-healthcheck
|
||||||
|
test: ["CMD-SHELL", "test -f /tmp/healthy"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Network owner for every dumbclient-* service and the capture sidecar below (see the comment on
|
||||||
|
# `capture`): all four are pure outbound TCP clients with nothing of their own listening, so
|
||||||
|
# sharing one netns is only ever a source-IP detail, never a port collision.
|
||||||
|
dumbclient-w2000:
|
||||||
|
build: ./interop-tests/peers/dumbclient
|
||||||
|
image: interop-dumbclient:de0334b
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/window2000.yml"]
|
||||||
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
|
||||||
|
# S9's comparison run: window below maxHeldMessages (1000, session-options.ts), where nothing
|
||||||
|
# should ever be evicted - see findings/07-load.md.
|
||||||
|
dumbclient-w500:
|
||||||
|
build: ./interop-tests/peers/dumbclient
|
||||||
|
image: interop-dumbclient:de0334b
|
||||||
|
<<: *log-limits
|
||||||
|
network_mode: "service:dumbclient-w2000"
|
||||||
|
command: ["conf/window500.yml"]
|
||||||
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
depends_on:
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# S6: sends one message, then never speaks again - the no-ping binary (see the Dockerfile) sends
|
||||||
|
# no enquire_link either, which nothing else built for this phase can say (smppload is blocked).
|
||||||
|
dumbclient-idle:
|
||||||
|
build: ./interop-tests/peers/dumbclient
|
||||||
|
image: interop-dumbclient:de0334b
|
||||||
|
<<: *log-limits
|
||||||
|
network_mode: "service:dumbclient-w2000"
|
||||||
|
environment:
|
||||||
|
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
|
||||||
|
command: ["conf/idle.yml"]
|
||||||
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
depends_on:
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# The long soak: the longest run the time-box allows, fast handler, watched for anything that
|
||||||
|
# grows without bound.
|
||||||
|
dumbclient-soak:
|
||||||
|
build: ./interop-tests/peers/dumbclient
|
||||||
|
image: interop-dumbclient:de0334b
|
||||||
|
<<: *log-limits
|
||||||
|
network_mode: "service:dumbclient-w2000"
|
||||||
|
command: ["conf/soak.yml"]
|
||||||
|
healthcheck: *dumbclient-healthcheck
|
||||||
|
depends_on:
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# Every dumbclient-* service shares dumbclient-w2000's netns (see above), so this one sidecar
|
||||||
|
# sees all four conversations with node:2775 - the same pattern compose.kannel.yaml uses for its
|
||||||
|
# four bearerbox variants, one namespace deeper.
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:dumbclient-w2000"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/dumbclient.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
dumbclient-idle:
|
||||||
|
condition: service_healthy
|
||||||
|
dumbclient-soak:
|
||||||
|
condition: service_healthy
|
||||||
|
dumbclient-w500:
|
||||||
|
condition: service_healthy
|
||||||
|
dumbclient-w2000:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Blocked (findings/07-load.md): builds and connects, but its bind_transceiver goes out on the
|
||||||
|
# wire two bytes short - a corrupted command_length/command_id no SMSC can parse. Kept running
|
||||||
|
# here (rather than removed) so smppload.test.ts's own reproducer stays exercised.
|
||||||
|
smppload:
|
||||||
|
build: ./interop-tests/peers/smppload
|
||||||
|
image: interop-smppload:2.5.3-49fb653
|
||||||
|
<<: *log-limits
|
||||||
|
environment:
|
||||||
|
SMPP_HOST: node
|
||||||
|
SMPP_PORT: "2775"
|
||||||
|
command: ["-H", "node", "-P", "2775", "-i", "sload-probe", "-p", "password", "-B", "trx", "-d", "15550001234", "-s", "15550005678", "-c", "1", "-r", "1", "-T", "1", "-l", "20", "--bind_timeout", "5000"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "test -f /tmp/healthy"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# smppload only ever dials node:2775 on this container's own network namespace, so this sees
|
||||||
|
# exactly its side of the exchange (same pattern as compose.kannel.yaml).
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:smppload"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
smppload:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smppload.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
smppload:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import type { LogMethod, SmppLog } from '../src/log.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
|
||||||
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
/** Slower than every scenario's submission rate (2000/s for the window runs), so a real backlog
|
||||||
|
* presses on the configured window instead of draining as fast as it fills - see findings/07-load.md. */
|
||||||
|
const SLOW_HANDLER_DELAY_MS = 2;
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget: number): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(50);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScenarioUserData = { systemId: string };
|
||||||
|
|
||||||
|
function isScenarioUserData(value: unknown): value is ScenarioUserData {
|
||||||
|
return typeof value === 'object' && value !== null && typeof (value as { systemId?: unknown }).systemId === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scenarioOf(session: Session): string {
|
||||||
|
return isScenarioUserData(session.userData) ? session.userData.systemId : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScenarioStats = {
|
||||||
|
answerOrder: number[];
|
||||||
|
answered: number;
|
||||||
|
arrived: number;
|
||||||
|
// Set from a 'close' listener attached the moment the session is first seen (smpp.on('session')),
|
||||||
|
// never lazily inside a test body - a session can close well before a test gets around to
|
||||||
|
// watching for it (S9 above may run for a minute; idleTimeout is 40s), and an EventEmitter never
|
||||||
|
// replays an event to a listener added after it fired.
|
||||||
|
closed: boolean;
|
||||||
|
duplicateIds: number;
|
||||||
|
ids: Set<string>;
|
||||||
|
peakOutstanding: number;
|
||||||
|
unansweredErrors: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = new Map<string, ScenarioStats>();
|
||||||
|
|
||||||
|
function statsFor(name: string): ScenarioStats {
|
||||||
|
const existing = stats.get(name);
|
||||||
|
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const created: ScenarioStats = {
|
||||||
|
answerOrder: [],
|
||||||
|
answered: 0,
|
||||||
|
arrived: 0,
|
||||||
|
closed: false,
|
||||||
|
duplicateIds: 0,
|
||||||
|
ids: new Set(),
|
||||||
|
peakOutstanding: 0,
|
||||||
|
unansweredErrors: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.set(name, created);
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogEntry = { level: string; message: string; metadata: Record<string, boolean | number | string> | undefined };
|
||||||
|
|
||||||
|
const logEntries: LogEntry[] = [];
|
||||||
|
|
||||||
|
function capture(level: string): LogMethod {
|
||||||
|
return (message, metadata) => { logEntries.push({ level, message, metadata }); };
|
||||||
|
}
|
||||||
|
|
||||||
|
const log: SmppLog = {
|
||||||
|
debug: capture('debug'),
|
||||||
|
error: capture('error'),
|
||||||
|
info: capture('info'),
|
||||||
|
verbose: capture('verbose'),
|
||||||
|
warn: capture('warn'),
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemSample = { heapUsed: number; rss: number; t: number };
|
||||||
|
|
||||||
|
const memSamples: MemSample[] = [];
|
||||||
|
const memTimer = setInterval(() => {
|
||||||
|
const usage = process.memoryUsage();
|
||||||
|
|
||||||
|
memSamples.push({ heapUsed: usage.heapUsed, rss: usage.rss, t: Date.now() });
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
memTimer.unref();
|
||||||
|
|
||||||
|
const { err, server: smpp } = await server({
|
||||||
|
authenticate: ({ systemId }) => ({ userData: { systemId } satisfies ScenarioUserData }),
|
||||||
|
idleTimeout: 40_000,
|
||||||
|
log,
|
||||||
|
port: SMPP_PORT,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
|
||||||
|
const serverErrors: Error[] = [];
|
||||||
|
|
||||||
|
smpp.on('serverError', serverError => { serverErrors.push(serverError); });
|
||||||
|
|
||||||
|
const sessionByScenario = new Map<string, Session>();
|
||||||
|
const slowQueues = new Map<Session, Promise<void>>();
|
||||||
|
|
||||||
|
function answered(session: Session, arrivalIndex: number, result: { err?: Error }): void {
|
||||||
|
const s = statsFor(scenarioOf(session));
|
||||||
|
|
||||||
|
s.answered++;
|
||||||
|
s.answerOrder.push(arrivalIndex);
|
||||||
|
if (result.err) s.unansweredErrors++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slowRespond(session: Session, sms: Sms, arrivalIndex: number): void {
|
||||||
|
const chain = (slowQueues.get(session) ?? Promise.resolve())
|
||||||
|
.then(async () => { await delay(SLOW_HANDLER_DELAY_MS); })
|
||||||
|
.then(async () => { answered(session, arrivalIndex, await sms.sendResp()); });
|
||||||
|
|
||||||
|
slowQueues.set(session, chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fastRespond(session: Session, sms: Sms, arrivalIndex: number): void {
|
||||||
|
void sms.sendResp().then(result => { answered(session, arrivalIndex, result); });
|
||||||
|
}
|
||||||
|
|
||||||
|
smpp.on('session', session => {
|
||||||
|
// Attached now, not lazily in a test body - see the comment on ScenarioStats.closed.
|
||||||
|
session.on('close', () => { statsFor(scenarioOf(session)).closed = true; });
|
||||||
|
|
||||||
|
session.on('sms', sms => {
|
||||||
|
const name = scenarioOf(session);
|
||||||
|
|
||||||
|
sessionByScenario.set(name, session);
|
||||||
|
|
||||||
|
const s = statsFor(name);
|
||||||
|
const arrivalIndex = s.arrived;
|
||||||
|
|
||||||
|
s.arrived++;
|
||||||
|
if (s.ids.has(sms.smsId)) s.duplicateIds++;
|
||||||
|
else s.ids.add(sms.smsId);
|
||||||
|
s.peakOutstanding = Math.max(s.peakOutstanding, s.arrived - s.answered);
|
||||||
|
|
||||||
|
if (name === 'dumb-w500' || name === 'dumb-w2000') slowRespond(session, sms, arrivalIndex);
|
||||||
|
else fastRespond(session, sms, arrivalIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function memShape(): string {
|
||||||
|
if (memSamples.length === 0) return 'no samples taken (run shorter than the 5s sample interval)';
|
||||||
|
|
||||||
|
const first = memSamples[0];
|
||||||
|
const last = memSamples[memSamples.length - 1];
|
||||||
|
|
||||||
|
assert.ok(first);
|
||||||
|
assert.ok(last);
|
||||||
|
|
||||||
|
const rssValues = memSamples.map(sample => sample.rss);
|
||||||
|
const peakRss = Math.max(...rssValues);
|
||||||
|
const minRss = Math.min(...rssValues);
|
||||||
|
const spanS = ((last.t - first.t) / 1000).toFixed(0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
`samples=${String(memSamples.length)} over ${spanS}s`,
|
||||||
|
`rss first=${String(Math.round(first.rss / 1024 / 1024))}MiB`,
|
||||||
|
`min=${String(Math.round(minRss / 1024 / 1024))}MiB`,
|
||||||
|
`max=${String(Math.round(peakRss / 1024 / 1024))}MiB`,
|
||||||
|
`last=${String(Math.round(last.rss / 1024 / 1024))}MiB`,
|
||||||
|
`heapUsed last=${String(Math.round(last.heapUsed / 1024 / 1024))}MiB`,
|
||||||
|
].join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSorted(values: number[]): boolean {
|
||||||
|
return values.every((value, index) => index === 0 || (values[index - 1] ?? 0) <= value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(line: string): void {
|
||||||
|
process.stdout.write(`${line}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
clearInterval(memTimer);
|
||||||
|
report(`memory shape: ${memShape()}`);
|
||||||
|
for (const [name, s] of stats) {
|
||||||
|
report(`${name}: arrived=${String(s.arrived)} answered=${String(s.answered)} duplicateIds=${String(s.duplicateIds)} peakOutstanding=${String(s.peakOutstanding)} unansweredErrors=${String(s.unansweredErrors)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await smpp.close();
|
||||||
|
|
||||||
|
for (const serverError of serverErrors) report(`serverError: ${serverError.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// S9 (target 11) and the backpressure-at-server scenario: window 2000 at a high rate against a
|
||||||
|
// handler slowed enough to build a real backlog. window500 is the same shape with a window below
|
||||||
|
// maxHeldMessages (1000, session-options.ts defaults.maxHeldMessages) - see findings/07-load.md for
|
||||||
|
// what that constant, rather than maxOutstanding, turns out to be the one that interacts with a
|
||||||
|
// peer's window.
|
||||||
|
describe('S9 - bounded window against a slowed handler', () => {
|
||||||
|
for (const [name, expectedCount] of [['dumb-w500', 20_000], ['dumb-w2000', 20_000]] as const) {
|
||||||
|
test(`${name}: every message answered exactly once, ordering holds`, async () => {
|
||||||
|
const done = await waitFor(() => (statsFor(name).answered >= expectedCount ? true : undefined), 180_000);
|
||||||
|
|
||||||
|
assert.ok(done, `${name} did not answer ${String(expectedCount)} messages within budget`);
|
||||||
|
|
||||||
|
const s = statsFor(name);
|
||||||
|
|
||||||
|
assert.equal(s.arrived, expectedCount);
|
||||||
|
assert.equal(s.answered, expectedCount);
|
||||||
|
assert.equal(s.duplicateIds, 0);
|
||||||
|
assert.equal(s.unansweredErrors, 0);
|
||||||
|
assert.equal(s.ids.size, expectedCount);
|
||||||
|
assert.ok(isSorted(s.answerOrder), `${name} answered out of arrival order`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('window 2000 pressed past maxHeldMessages (1000): the internal held-message cap evicts, window500 never does', async () => {
|
||||||
|
await waitFor(() => (statsFor('dumb-w2000').answered >= 20_000 ? true : undefined), 180_000);
|
||||||
|
|
||||||
|
const evictions = logEntries.filter(entry => entry.message === 'heldMessages - buffer full, dropping the oldest message');
|
||||||
|
|
||||||
|
// window500's peak (<=500) never reaches the 1000 default, so any eviction observed is
|
||||||
|
// necessarily from the w2000 session - the two runs share one server and one log.
|
||||||
|
assert.ok(evictions.length > 0, 'expected at least one held-message eviction under window 2000');
|
||||||
|
// The peer's own window, respected exactly both runs (peakOutstanding read 500 and 2000 on
|
||||||
|
// the nose) - the lower bound is what distinguishes this from window500's own eviction-free run.
|
||||||
|
assert.ok(statsFor('dumb-w2000').peakOutstanding > 1000 && statsFor('dumb-w2000').peakOutstanding <= 2000);
|
||||||
|
assert.equal(statsFor('dumb-w500').peakOutstanding <= 500, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memory after the backlog drains back down is close to before either window run started', async () => {
|
||||||
|
const before = memSamples[0];
|
||||||
|
|
||||||
|
assert.ok(before, 'no memory sample taken before the window runs started');
|
||||||
|
|
||||||
|
// Node's GC is opportunistic, so this is printed evidence of the shape (per findings/07-load.md),
|
||||||
|
// not a hard bound - a real leak reads as a trend across the whole run's samples, not one pair.
|
||||||
|
await delay(5000);
|
||||||
|
|
||||||
|
const after = process.memoryUsage();
|
||||||
|
|
||||||
|
report(
|
||||||
|
`memory around the window runs: before=${String(Math.round(before.rss / 1024 / 1024))}MiB `
|
||||||
|
+ `after=${String(Math.round(after.rss / 1024 / 1024))}MiB`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// S6 (target: idleTimeout) - a peer that sends one message and then, using the no-ping binary
|
||||||
|
// (Dockerfile), never speaks again: no enquire_link, ever. smppload was meant to be this peer and
|
||||||
|
// is blocked (findings/07-load.md), so this is the substitute.
|
||||||
|
describe('S6 - idle peer, no enquire_link at all', () => {
|
||||||
|
test('our server drops it at idleTimeout, with no response sent past the one it owed', async () => {
|
||||||
|
const bound = await waitFor(() => (statsFor('dumb-idle').arrived >= 1 ? true : undefined), 20_000);
|
||||||
|
|
||||||
|
assert.ok(bound, 'dumb-idle never submitted its one message');
|
||||||
|
|
||||||
|
// idleTimeout is 40s from the last byte the peer sent (its submit_sm), never from our own
|
||||||
|
// writes (link-timers.ts resets only on inbound data). This test may start running well
|
||||||
|
// past that mark on its own (S9 above can take a minute) - statsFor(...).closed is set from
|
||||||
|
// a 'close' listener attached at session-creation time, so a close from before this test
|
||||||
|
// even started is still seen; budget is slack for a session that is still open, not a clock.
|
||||||
|
const droppedIdle = await waitFor(() => (statsFor('dumb-idle').closed ? true : undefined), 60_000);
|
||||||
|
|
||||||
|
assert.ok(droppedIdle, 'server never dropped the idle peer within idleTimeout + slack');
|
||||||
|
assert.ok(logEntries.some(entry => entry.message === 'linkTimers - closing an idle peer'));
|
||||||
|
// teardown() (session.ts) is a raw close, not an unbind exchange - nothing further is on the
|
||||||
|
// wire for this session, which the capture's histogram (findings/07-load.md) confirms.
|
||||||
|
assert.equal(statsFor('dumb-idle').answered, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The long soak: the longest run the time-box allows, fast handler, watched for anything that
|
||||||
|
// grows without bound (held messages, listeners, memory). Bounded by wall-clock rather than a
|
||||||
|
// target count: smpp-dumb-client's own TX-tracking window bookkeeping stalls under sustained load
|
||||||
|
// (findings/07-load.md, Peer quirks) well short of the configured count, on the client's side only
|
||||||
|
// - our own arrived/answered stay in lockstep throughout, which is what this asserts.
|
||||||
|
describe('Long soak', () => {
|
||||||
|
const SOAK_DURATION_MS = 300_000;
|
||||||
|
|
||||||
|
test('the longest run the time-box allows: every arrival answered, nothing duplicated, memory does not grow without bound', async () => {
|
||||||
|
await delay(SOAK_DURATION_MS);
|
||||||
|
|
||||||
|
// One more turn for a response mid-flight when the clock ran out to land, not a target count.
|
||||||
|
await waitFor(() => {
|
||||||
|
const s = statsFor('dumb-soak');
|
||||||
|
|
||||||
|
return s.arrived === s.answered ? true : undefined;
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
const s = statsFor('dumb-soak');
|
||||||
|
|
||||||
|
report(`soak reached: arrived=${String(s.arrived)} over ${String(SOAK_DURATION_MS / 1000)}s`);
|
||||||
|
|
||||||
|
assert.ok(s.arrived > 0, 'dumb-soak never submitted anything');
|
||||||
|
assert.equal(s.answered, s.arrived);
|
||||||
|
assert.equal(s.duplicateIds, 0);
|
||||||
|
assert.equal(s.unansweredErrors, 0);
|
||||||
|
|
||||||
|
const session = sessionByScenario.get('dumb-soak');
|
||||||
|
|
||||||
|
assert.ok(session);
|
||||||
|
|
||||||
|
const closed = await session.close();
|
||||||
|
|
||||||
|
assert.equal(closed.err, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# 07 load
|
||||||
|
|
||||||
|
Date: 2026-09-06. Repo commit: `ab93833`. Host: AMD Ryzen 9 5950X, 8 vCPUs allotted, 31GB RAM,
|
||||||
|
Alpine 6.18.38-0-virt kernel, Docker 29.6.2. Images: `interop-smppload:2.5.3-49fb653` (smppload
|
||||||
|
cloned at commit `49fb653`, tag 2.5.3, built with `erlang:27.3.4.17-alpine` + a fresh `rebar3`
|
||||||
|
3.27.0 release replacing the commit's own pre-OTP-27 vendored one), `interop-dumbclient:de0334b`
|
||||||
|
(vponomarev/libsmpp cloned at commit `de0334b`, built with `golang:1.26.8-alpine3.23` on
|
||||||
|
`alpine:3.23.5`), `nicolaka/netshoot:v0.16` (capture sidecar and tshark), `node:24.18.0-bookworm-slim`
|
||||||
|
(test runner, from the root `compose.yaml`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### smppload: builds, binds, then puts a corrupted PDU on the wire - blocked
|
||||||
|
|
||||||
|
Issue #8 (rebar3/BEAM load errors) is real but resolved in minutes: the commit's own vendored
|
||||||
|
`./rebar3` escript predates OTP 27 and fails to load under it
|
||||||
|
(`please re-compile this module with an Erlang/OTP 27 compiler`). Replacing it with a fresh rebar3
|
||||||
|
3.27.0 release before `make escriptize` fixes the build outright - no further patching needed, and
|
||||||
|
`git://` dependency URLs in `rebar.config` resolved fine once rewritten to `https://` (a one-line
|
||||||
|
`git config --global url.insteadOf`).
|
||||||
|
|
||||||
|
The built escript is not usable against any SMSC, though: every `bind_transceiver` it sends is two
|
||||||
|
octets short of what its own `command_length` declares. Captured with a raw tshark sidecar against
|
||||||
|
`ukarim/smscsim:0.2.0` (independent of both this library and smppload's own logging):
|
||||||
|
|
||||||
|
```
|
||||||
|
002a000000090000000000000001736d7070636c69656e74310070617373776f7264000050010100
|
||||||
|
```
|
||||||
|
|
||||||
|
40 octets on the wire, but `command_length` (the first 4 octets, if the PDU were whole) would need
|
||||||
|
to read `0000002a` (42) for a `system_id` "smppclient1" / password "password" bind - the actual
|
||||||
|
first two octets of that field are simply missing, so the wire instead starts `002a0000`
|
||||||
|
(2,752,512) with `command_id` and everything after shifted two octets early. tshark's own SMPP
|
||||||
|
dissector does not recognise the stream as SMPP at all (`-Y smpp` matches zero frames, though the
|
||||||
|
raw capture has the SYN/ACK/PSH/FIN sequence and the 40-octet data frame) - a second, independent
|
||||||
|
confirmation this is not merely a framing quirk our own codec is stricter about.
|
||||||
|
|
||||||
|
Traced as far as `oserl`'s `smpp_pdu_syntax:pack/2` (the `trx_deadlock_fix_1` branch smppload's
|
||||||
|
`rebar.config` pins), which builds the header as plain 32-bit bit-syntax
|
||||||
|
(`<<Len:32, CmdId:32, 0:32, SeqNum:32>>`) - correct on inspection, so the corruption happens
|
||||||
|
somewhere between that call and the socket write, not chased further given the time-box. Reproduced
|
||||||
|
identically on three separate runs (byte-for-byte). Recorded as **blocked**; `smppload.test.ts`
|
||||||
|
keeps a live reproducer asserting what our server does when it receives it (refuses the stream as
|
||||||
|
unframeable - see Scenarios) rather than removing the peer. `smpp-dumb-client` covers S9, and
|
||||||
|
substitutes for S6 and (partially) S8 - see below.
|
||||||
|
|
||||||
|
### smpp-dumb-client: builds and interoperates cleanly; its own window bookkeeping stalls under sustained load
|
||||||
|
|
||||||
|
No build friction. Two binaries from the same pinned source: `smpp-dumb-client` (unmodified) and
|
||||||
|
`smpp-dumb-client-noping` (its two `enquireSender()` call sites in `smpp.go` commented out at build
|
||||||
|
time), the second built because every load tool built for this phase sends `enquire_link` on its
|
||||||
|
own otherwise (`smpp-dumb-client` every 10s, unconditionally, not configurable) and smppload - the
|
||||||
|
one peer that genuinely never does - is blocked, leaving S6 with no peer at all otherwise.
|
||||||
|
|
||||||
|
One integration snag, not a build one: `smpp.remote` in `config.yml` is fed straight into
|
||||||
|
`net.ParseIP` (`hdr.go`) with no DNS resolution at all, so the compose service name cannot appear
|
||||||
|
there directly. Fixed in the entrypoint: every `conf/*.yml` carries a `NODE_HOST` placeholder,
|
||||||
|
resolved with `getent hosts` and substituted into a writable copy before the real binary starts.
|
||||||
|
|
||||||
|
Four one-shot scenarios share `dumbclient-w2000`'s network namespace (`network_mode:
|
||||||
|
"service:dumbclient-w2000"`) - they are pure outbound clients with nothing of their own listening,
|
||||||
|
so the only shared cost is a source IP, and one capture sidecar sees all four conversations with
|
||||||
|
`node:2775` the same way `compose.kannel.yaml`'s does for its four bearerbox variants.
|
||||||
|
|
||||||
|
The long soak (below) surfaced a peer-side limit worth designing around rather than fighting: with
|
||||||
|
a fast, immediate-response handler and a window of 100 - nothing our server should ever have
|
||||||
|
trouble draining - the peer's own reported in-flight count (`GetTrackQueueSize`, read from
|
||||||
|
`len(TrackTX)`) gets stuck pinned at the window within the first minute, and its log fills with
|
||||||
|
`Expired TX packet` lines (`libsmpp`'s hardcoded, non-configurable 7000ms `TX_MAX_TIMEOUT_MS`) -
|
||||||
|
throughput drops from ~500/s to a trickle of tens per second, gated by how many tracked entries
|
||||||
|
individually cross that 7s mark each second rather than by real responses being matched. Our own
|
||||||
|
server-side counters (`arrived`/`answered`/`peakOutstanding`, tracked independently in
|
||||||
|
`dumbclient.test.ts`) stay in lockstep throughout with a low peak - see Scenarios - which places the
|
||||||
|
stall entirely on the peer's own window bookkeeping, not on anything our server did or failed to
|
||||||
|
do. The soak test was redesigned around this: bounded by wall-clock (5 minutes) rather than a
|
||||||
|
target count, asserting the invariants that matter regardless of how much the peer's own bug lets
|
||||||
|
through (every arrival answered, nothing duplicated, memory shape), and reporting whatever
|
||||||
|
throughput was actually reached rather than requiring a specific one.
|
||||||
|
|
||||||
|
One test-harness bug found and fixed between the two runs below, not a library defect: the S6 test's
|
||||||
|
first version attached its `session.on('close', ...)` listener lazily inside the test body, after
|
||||||
|
already waiting on the S9 assertions (which can run for the better part of a minute) - by the time
|
||||||
|
the S6 test ran, the idle session had already closed, and an `EventEmitter` never replays a past
|
||||||
|
event to a listener added after it fired. Fixed by attaching every session's `close` listener at
|
||||||
|
`session`-creation time, recording it in the same per-scenario stats every other assertion reads.
|
||||||
|
|
||||||
|
Two runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced
|
||||||
|
both the peer's TX-tracking stall and the S6 harness bug above; run 2, after both fixes, is the one
|
||||||
|
reported below. `smppload.test.ts` passed on every run it was given (three, across the investigation
|
||||||
|
above); its one scenario needs no repeat - a second run reproduces the identical corrupted PDU,
|
||||||
|
adding nothing.
|
||||||
|
|
||||||
|
```
|
||||||
|
dumbclient run 2: frames 111300, bind_transceiver 4/4, enquire_link 12 (enquire_link_resp 9 - the
|
||||||
|
capture stops moments after the test does, catching some requests before their
|
||||||
|
response), submit_sm 62441, submit_sm_resp 48830, malformed 0, expert errors 0
|
||||||
|
smppload: frames 0 (tshark's own SMPP dissector does not recognise the corrupted stream at all)
|
||||||
|
```
|
||||||
|
|
||||||
|
`submit_sm_resp` reads lower than `submit_sm` in the capture for the same reason
|
||||||
|
`enquire_link_resp` does - the sidecar is stopped right after the test file's own `after()` hook
|
||||||
|
finishes, which is itself moments after the last response goes out, so a handful of writes land
|
||||||
|
after the capture stops seeing them. Not a lost response: `submit_sm` (62441) matches the sum of
|
||||||
|
every session's own `arrived` exactly, and every session's own `answered` matches its `arrived` too
|
||||||
|
(see Scenarios) - both counted independently, in the server process, of anything on the wire.
|
||||||
|
|
||||||
|
## Throughput and memory
|
||||||
|
|
||||||
|
`dumb-w500` and `dumb-w2000` (S9) both ran to their full 20,000-message count in ~44s each,
|
||||||
|
concurrently, against a handler serialised to answer roughly one message every 2ms
|
||||||
|
(`SLOW_HANDLER_DELAY_MS`) - `peakOutstanding` read exactly 500 and exactly 2000, the two configured
|
||||||
|
windows, confirming the peer never let more than its own window ride at once.
|
||||||
|
|
||||||
|
The soak (fast, immediate-response handler; window 100) reached 22,440 `submit_sm` over its fixed
|
||||||
|
300s observation window - about 75/s, well under the peer's own configured `rate: 500` and under
|
||||||
|
what our server can sustain (see Setup: `smpp-dumb-client`'s own TX-tracking bookkeeping is the
|
||||||
|
ceiling here, not our server - `peakOutstanding` stayed at 25 throughout). Sampled every 5s across
|
||||||
|
the whole run (69 samples over 340s, all four scenarios combined): rss first=170MiB, min=124MiB,
|
||||||
|
max=306MiB (during the two window runs' backlog), last=125MiB, heapUsed at the last sample 15MiB -
|
||||||
|
back below its own starting point once the backlog drained, not merely flat. No monotonic trend in
|
||||||
|
either direction.
|
||||||
|
|
||||||
|
## Scenarios (PLAN.md)
|
||||||
|
|
||||||
|
| Id | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| S6 (idleTimeout, no peer ever pings) | pass | `dumbclient.test.ts` "S6 - idle peer..." - dropped at idleTimeout, `linkTimers - closing an idle peer` logged, no response past the one owed |
|
||||||
|
| S8 (throughput, long messages, receipts) | blocked (smppload) / partial substitute | smppload's own scenario is blocked - see Setup. The soak below gives a genuine submit_sm/s figure without long messages or receipts, which `smpp-dumb-client` does not support (`research/esme-clients-and-validators.md` section B) - and is itself capped well below what our server can sustain by the peer's own TX-tracking stall, also see Setup |
|
||||||
|
| S9 (bounded window) | pass | `dumbclient.test.ts` "S9 - bounded window..." - 20,000/20,000 answered on both window 500 and window 2000, in arrival order, no duplicate ids, `peakOutstanding` exactly 500 and exactly 2000 |
|
||||||
|
| Backpressure at the server | pass | Same run: `peakOutstanding` 2000 exceeds `maxHeldMessages` (1000, session-options.ts) and the eviction warning fires; window 500 (`peakOutstanding` 500) never does; memory sampled before/after the window runs (170MiB before, 306MiB after, 125MiB once the soak's own run had also settled) |
|
||||||
|
| Long soak | pass (run once at the redesigned, wall-clock-bounded shape - see Setup) | `dumbclient.test.ts` "Long soak" - 22,440 arrived, 22,440 answered, 0 duplicates, 0 unanswered errors, `close()` drains with no error |
|
||||||
|
| smppload bind corruption (not in PLAN.md - found this phase) | blocked | `smppload.test.ts` - our server refuses the unreadable stream instead of hanging |
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
None found. `smppload.test.ts`'s own scenario is smppload's defect, not ours: our server's reaction
|
||||||
|
(refusing the stream as unframeable, per the decision in the root `AGENTS.md`, "A stream this
|
||||||
|
library cannot frame...") is the documented behaviour working exactly as designed against a peer
|
||||||
|
that never gets as far as a readable PDU. The soak's throughput ceiling is the peer's own defect
|
||||||
|
(see Setup) - our own `arrived`/`answered`/`peakOutstanding` counters stayed clean throughout every
|
||||||
|
run.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
- **smppload's `bind_transceiver` is corrupted on the wire** - see Setup. Not chased past `oserl`'s
|
||||||
|
`pack/2` (which is correct on inspection) given the time-box.
|
||||||
|
- **`smpp-dumb-client`'s window bookkeeping stalls under sustained load, throttling its own
|
||||||
|
throughput far below what a promptly-answering server can sustain** - see Setup. Its `enquire_link`
|
||||||
|
interval (10s once bound as an ESME) is also hardcoded (`smpp.go`, `enquireSender(10)`), not
|
||||||
|
exposed through `config.yml` at all - the no-ping binary built for S6 patches the call site out
|
||||||
|
rather than configuring it.
|
||||||
|
- **`smpp.remote` takes a literal IP, never a hostname** (`net.ParseIP`, no DNS resolution) - see
|
||||||
|
Setup.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether smppload's bind corruption is in `oserl`'s `gen_esme_session`/`smpp_session` send path
|
||||||
|
(not reached, given the time-box) or something specific to this build's dependency versions.
|
||||||
|
- Whether `smpp-dumb-client`'s stall is a sequence-number correlation bug (a response failing to
|
||||||
|
match its `TrackTX` entry, falling back to the 7s expiry) or something else in its own window
|
||||||
|
accounting - not chased past the observation in Setup, given the time-box and that the fault is
|
||||||
|
clearly on the peer's side (our own counters stayed clean throughout).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# smpp-dumb-client has no published image; built from source at a pinned commit. A second binary
|
||||||
|
# is built from the same source with its own automatic enquire_link neutralised, for S6 - see
|
||||||
|
# findings/07-load.md for why (every peer built for this phase sends enquire_link on its own
|
||||||
|
# otherwise, and smppload, the one that never does, is blocked).
|
||||||
|
FROM --platform=linux/amd64 golang:1.26.8-alpine3.23 AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
ARG LIBSMPP_COMMIT=de0334bf2c1155fb3dd4f929674968254c932be6
|
||||||
|
|
||||||
|
RUN git clone https://github.com/vponomarev/libsmpp.git /build \
|
||||||
|
&& cd /build \
|
||||||
|
&& git checkout "${LIBSMPP_COMMIT}"
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
RUN go build -o /out/smpp-dumb-client ./app/smpp-dumb-client
|
||||||
|
|
||||||
|
# libsmpp's enquireSender(60)/enquireSender(10) are the two call sites that start its unconditional,
|
||||||
|
# unconfigurable 10s/60s enquire_link ticker (smpp.go) - both neutralised here for the no-ping build.
|
||||||
|
RUN sed -i \
|
||||||
|
-e 's/go s\.enquireSender(60)/\/\/ patched for S6 (no enquire_link): &/' \
|
||||||
|
-e 's/go s\.enquireSender(10)/\/\/ patched for S6 (no enquire_link): &/' \
|
||||||
|
smpp.go \
|
||||||
|
&& go build -o /out/smpp-dumb-client-noping ./app/smpp-dumb-client
|
||||||
|
|
||||||
|
FROM --platform=linux/amd64 alpine:3.23.5 AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache netcat-openbsd
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /out/smpp-dumb-client /app/smpp-dumb-client
|
||||||
|
COPY --from=builder /out/smpp-dumb-client-noping /app/smpp-dumb-client-noping
|
||||||
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
COPY conf ./conf
|
||||||
|
RUN chmod +x /app/entrypoint.sh /app/smpp-dumb-client /app/smpp-dumb-client-noping
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
rate: yes
|
||||||
|
netbuf: false
|
||||||
|
|
||||||
|
profiler:
|
||||||
|
enabled: no
|
||||||
|
|
||||||
|
smpp:
|
||||||
|
remote: NODE_HOST:2775
|
||||||
|
bind:
|
||||||
|
systemID: dumb-idle
|
||||||
|
systemType: ""
|
||||||
|
password: dumbpw
|
||||||
|
mode: TRX
|
||||||
|
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
message:
|
||||||
|
from:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550005678"
|
||||||
|
to:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550001234"
|
||||||
|
template: no
|
||||||
|
registeredDelivery: 0
|
||||||
|
dataCoding: 1
|
||||||
|
body: "load phase 7 - S6 idle probe"
|
||||||
|
count: 1
|
||||||
|
rate: 1
|
||||||
|
window: 10
|
||||||
|
# Sends its one message, gets the response, then goes silent - run through the no-ping binary
|
||||||
|
# (see the Dockerfile), so nothing at all crosses the wire after that: no enquire_link, no traffic.
|
||||||
|
stayConnected: yes
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
rate: yes
|
||||||
|
netbuf: false
|
||||||
|
|
||||||
|
profiler:
|
||||||
|
enabled: no
|
||||||
|
|
||||||
|
smpp:
|
||||||
|
remote: NODE_HOST:2775
|
||||||
|
bind:
|
||||||
|
systemID: dumb-soak
|
||||||
|
systemType: ""
|
||||||
|
password: dumbpw
|
||||||
|
mode: TRX
|
||||||
|
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
message:
|
||||||
|
from:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550005678"
|
||||||
|
to:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550001234"
|
||||||
|
template: no
|
||||||
|
registeredDelivery: 0
|
||||||
|
dataCoding: 1
|
||||||
|
body: "load phase 7 - long soak, fast handler"
|
||||||
|
count: 300000
|
||||||
|
rate: 500
|
||||||
|
window: 100
|
||||||
|
stayConnected: yes
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
rate: yes
|
||||||
|
netbuf: false
|
||||||
|
|
||||||
|
profiler:
|
||||||
|
enabled: no
|
||||||
|
|
||||||
|
smpp:
|
||||||
|
remote: NODE_HOST:2775
|
||||||
|
bind:
|
||||||
|
systemID: dumb-w2000
|
||||||
|
systemType: ""
|
||||||
|
password: dumbpw
|
||||||
|
mode: TRX
|
||||||
|
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
message:
|
||||||
|
from:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550005678"
|
||||||
|
to:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550001234"
|
||||||
|
template: no
|
||||||
|
registeredDelivery: 0
|
||||||
|
dataCoding: 1
|
||||||
|
body: "load phase 7 - window 2000, above maxHeldMessages (S9)"
|
||||||
|
count: 20000
|
||||||
|
rate: 2000
|
||||||
|
window: 2000
|
||||||
|
stayConnected: no
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
rate: yes
|
||||||
|
netbuf: false
|
||||||
|
|
||||||
|
profiler:
|
||||||
|
enabled: no
|
||||||
|
|
||||||
|
smpp:
|
||||||
|
remote: NODE_HOST:2775
|
||||||
|
bind:
|
||||||
|
systemID: dumb-w500
|
||||||
|
systemType: ""
|
||||||
|
password: dumbpw
|
||||||
|
mode: TRX
|
||||||
|
|
||||||
|
generator:
|
||||||
|
enabled: yes
|
||||||
|
message:
|
||||||
|
from:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550005678"
|
||||||
|
to:
|
||||||
|
ton: 1
|
||||||
|
npi: 1
|
||||||
|
addr: "15550001234"
|
||||||
|
template: no
|
||||||
|
registeredDelivery: 0
|
||||||
|
dataCoding: 1
|
||||||
|
body: "load phase 7 - window 500, below maxHeldMessages"
|
||||||
|
count: 20000
|
||||||
|
rate: 2000
|
||||||
|
window: 500
|
||||||
|
stayConnected: no
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# smpp-dumb-client is one-shot and dials out, so it needs node:2775 already listening - the same
|
||||||
|
# problem compose.smppload.yaml's entrypoint solves, and the same fix: a healthcheck this marker
|
||||||
|
# satisfies at once, and node depends on it rather than the other way round.
|
||||||
|
#
|
||||||
|
# Its own smpp.remote config field is fed straight into net.ParseIP with no DNS resolution at all
|
||||||
|
# (hdr.go), so the compose service name in every conf/*.yml is a NODE_HOST placeholder, resolved
|
||||||
|
# here and substituted into a writable copy before the real binary ever sees the config file.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
touch /tmp/healthy
|
||||||
|
|
||||||
|
host="${SMPP_HOST:-node}"
|
||||||
|
port="${SMPP_PORT:-2775}"
|
||||||
|
|
||||||
|
until nc -z "$host" "$port"; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
ip="$(getent hosts "$host" | awk '{print $1}' | head -n1)"
|
||||||
|
sed "s/NODE_HOST/$ip/" "$1" > /tmp/effective-config.yml
|
||||||
|
|
||||||
|
exec "${DUMBCLIENT_BIN:-/app/smpp-dumb-client}" -config /tmp/effective-config.yml
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# smppload has no published image; built from source at a pinned commit (tag 2.5.3). Its deps
|
||||||
|
# resolve some sub-deps over git:// (rebar.config), rewritten to https below - see findings/07-load.md.
|
||||||
|
FROM --platform=linux/amd64 erlang:27.3.4.17-alpine AS builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl git make build-base ca-certificates \
|
||||||
|
&& git config --global url."https://github.com/".insteadOf "git://github.com/"
|
||||||
|
|
||||||
|
ARG SMPPLOAD_COMMIT=49fb653966081052fa5d36fbadbdb0972f25ded5
|
||||||
|
ARG REBAR3_VERSION=3.27.0
|
||||||
|
|
||||||
|
# The commit's own vendored ./rebar3 escript predates OTP 27 and cannot even load under it
|
||||||
|
# ("please re-compile this module with an Erlang/OTP 27 compiler") - issue #8's BEAM load error.
|
||||||
|
# A current rebar3 release, which still reads this project's rebar.config, replaces it.
|
||||||
|
RUN curl -fsSL -o /usr/local/bin/rebar3 "https://github.com/erlang/rebar3/releases/download/${REBAR3_VERSION}/rebar3" \
|
||||||
|
&& chmod +x /usr/local/bin/rebar3
|
||||||
|
|
||||||
|
RUN git clone https://github.com/PowerMeMobile/smppload.git /build \
|
||||||
|
&& cd /build \
|
||||||
|
&& git checkout "${SMPPLOAD_COMMIT}" \
|
||||||
|
&& cp /usr/local/bin/rebar3 ./rebar3 \
|
||||||
|
&& make escriptize
|
||||||
|
|
||||||
|
FROM --platform=linux/amd64 erlang:27.3.4.17-alpine AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache netcat-openbsd
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /build/_build/default/bin/smppload /app/smppload
|
||||||
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh /app/smppload
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# smppload is one-shot: it needs node:2775 already accepting connections, but compose starts this
|
||||||
|
# container first (its healthcheck is a bare marker file, not the SMPP link) so node can depend on
|
||||||
|
# it the same way compose.kannel.yaml's bearerbox does. This loop is the retry Kannel's own client
|
||||||
|
# gives it for free.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
touch /tmp/healthy
|
||||||
|
|
||||||
|
host="${SMPP_HOST:-node}"
|
||||||
|
port="${SMPP_PORT:-2775}"
|
||||||
|
|
||||||
|
until nc -z "$host" "$port"; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
exec /app/smppload "$@"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { after, describe } from 'node:test';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
|
||||||
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { err, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await smpp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// smppload 2.5.3 (built at interop-tests/peers/smppload, its issue #8 rebar3/BEAM friction worked
|
||||||
|
// around with a current rebar3 release) is otherwise blocked: every bind_transceiver it puts on the
|
||||||
|
// wire is two octets short of what its own command_length declares, corrupting command_length and
|
||||||
|
// command_id both - findings/07-load.md carries the tshark capture. S6 and S8, both scoped to this
|
||||||
|
// peer in PLAN.md, could not run; dumbclient.test.ts carries the load and window scenarios instead.
|
||||||
|
describe('smppload (blocked)', () => {
|
||||||
|
test('the corrupted bind_transceiver is refused as an unframeable stream, not left to hang', async () => {
|
||||||
|
let session: Session | undefined;
|
||||||
|
let sessionErr: Error | undefined;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
smpp.on('session', incoming => {
|
||||||
|
session = incoming;
|
||||||
|
incoming.on('sessionError', sessionError => { sessionErr = sessionError; });
|
||||||
|
incoming.on('close', () => { closed = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
const bound = await waitFor(() => session, 20_000);
|
||||||
|
|
||||||
|
assert.ok(bound, 'smppload never opened a TCP connection to node:2775');
|
||||||
|
|
||||||
|
const refusal = await waitFor(() => sessionErr, 10_000);
|
||||||
|
|
||||||
|
assert.ok(refusal);
|
||||||
|
// maxPduLength (pdu-refusal.ts) is 1MiB; the corrupted command_length (0x2a shifted into the
|
||||||
|
// high bytes) reads as roughly 2.75M, so this is the "unreadable stream" teardown, not the
|
||||||
|
// "one bad PDU, link stays up" path - see AGENTS.md, "A stream this library cannot frame...".
|
||||||
|
assert.match(refusal.message, /Refusing a cmd_length of \d+/);
|
||||||
|
|
||||||
|
await waitFor(() => (closed ? true : undefined), 5000);
|
||||||
|
assert.equal(closed, true);
|
||||||
|
assert.ok(bound);
|
||||||
|
assert.equal(bound.loggedIn, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user