178 Commits

Author SHA1 Message Date
lilleman 2e4d1c4041 Merge pull request #35 from larvit/renovate/portfinder-1.x
Update dependency portfinder to v1.0.20
2019-02-19 19:23:47 +01:00
Renovate Bot 27bc7d3243 Update dependency portfinder to v1.0.20 2019-02-19 18:23:40 +00:00
lilleman fd6cfb6343 Merge pull request #36 from larvit/renovate/eslint-5.x
Update dependency eslint to v5.14.1
2019-02-19 19:23:37 +01:00
lilleman c4b7f55e5c Merge pull request #37 from larvit/renovate/mocha-eslint-5.x
Update dependency mocha-eslint to v5
2019-02-19 19:23:30 +01:00
Renovate Bot 64abd65fb4 Update dependency mocha-eslint to v5 2019-02-19 18:23:18 +00:00
lilleman 1ada19d2fe Merge pull request #39 from larvit/renovate/mocha-6.x
Update dependency mocha to v6
2019-02-19 19:23:04 +01:00
Renovate Bot cfc1530bfc Update dependency mocha to v6 2019-02-18 21:20:51 +00:00
Renovate Bot 5d58aebf71 Update dependency eslint to v5.14.1 2019-02-18 17:29:12 +00:00
lilleman 10c30a6682 Merge pull request #34 from larvit/renovate/portfinder-1.x
Update dependency portfinder to v1.0.18
2018-10-19 10:08:54 +02:00
lilleman e9a4133512 Merge pull request #31 from larvit/renovate/eslint-5.x
Update dependency eslint to v5.7.0
2018-10-19 10:08:39 +02:00
Renovate Bot 7c13882493 Update dependency portfinder to v1.0.18 2018-10-17 23:40:39 +00:00
Renovate Bot 933a9ed083 Update dependency eslint to v5.7.0 2018-10-12 20:54:01 +00:00
lilleman 834f878484 Nicer travis config file 2018-08-22 17:05:57 +02:00
lilleman 81be0a9aea Dependency updates and new API for logging 2018-08-22 17:05:29 +02:00
lilleman d9d3babe3b Merge pull request #30 from larvit/renovate/eslint-5.x
Update dependency eslint to v5
2018-08-22 16:03:30 +02:00
Renovate Bot cb55c0ea8f Update dependency eslint to v5 2018-08-22 14:02:30 +00:00
lilleman dae8965c2b Merge pull request #29 from larvit/renovate/pin-dependencies
Pin dependencies
2018-08-22 16:02:20 +02:00
Renovate Bot c68edb910c Pin dependencies 2018-08-22 14:01:28 +00:00
lilleman b61b0a0427 Merge pull request #28 from larvit/renovate/configure
Configure Renovate
2018-08-22 16:01:20 +02:00
Renovate Bot a68f923ac8 Add renovate.json 2018-07-31 10:58:12 +00:00
lilleman 8d17f1790b Drop support for node 4 2018-06-13 10:57:17 +02:00
lilleman a356c64343 Lint fixes and dependency updates 2018-06-13 10:54:42 +02:00
lilleman eda6f90cee Merge pull request #27 from qasimakhan/master
Patch for Malformed PDU with cmdLength 0. Issue #25.
2018-06-13 10:46:36 +02:00
Qasim Ayyaz Khan 44a3859dc9 Patch for Malformed PDU with cmdLength 0. Issue #25. 2018-06-13 08:00:11 +00:00
lilleman efcc303ec0 Updated version with latest supported versions of dependencies 2018-03-09 13:36:18 +01:00
lilleman a8618a8216 New version of package-lock.json 2018-03-09 13:35:51 +01:00
lilleman b80254aa12 Version bump setting smsId to uuid instead of empty string in case it is missing 2018-03-09 13:31:44 +01:00
lilleman 1756971e8f Merge pull request #24 from qasimakhan/master
Adding random uuid to message id if not assigned. It makes sure that …
2018-03-09 13:30:28 +01:00
Qasim Ayyaz Khan 20c3b87ea7 Adding random uuid to message id if not assigned. It makes sure that the DLR's are mapped properly for smpp clients. 2018-03-09 11:27:57 +00:00
lilleman 8f34bded65 Minor test copy change 2017-06-25 02:17:57 +02:00
lilleman db64b9e309 Update LICENSE 2017-06-25 02:13:48 +02:00
lilleman 7cc0f4f94d var to let or const, better lints, code style and more 2017-06-25 02:10:26 +02:00
lilleman a3d253994f Merge branch 'master' of github.com:larvit/larvitsmpp 2017-06-24 13:22:34 +02:00
lilleman 5a46633374 Upped version and fixed better require paths in index file 2017-06-24 13:22:20 +02:00
lilleman 20ccbca9d3 Merge pull request #18 from Nimblr/jsdoc
Change to JSDoc type formatting {type}
2017-06-24 13:08:21 +02:00
lilleman da013fea70 More node versions for travis. Package lock file. Removal of pre-git. 2017-06-24 13:06:26 +02:00
lilleman 745b9830ed Merge pull request #19 from Nimblr/fixEncodingTest
Fix LATIN1 decode test
2017-06-24 13:05:38 +02:00
lilleman 754d808033 Merge pull request #21 from Nimblr/localMochaEslint
Use project eslint and mocha instead of global ones
2017-06-24 12:03:58 +02:00
lilleman b196cb9206 Merge pull request #22 from Nimblr/fixTlsOptionsTypo
Fix TLS options typo, missing "s"
2017-06-24 12:01:29 +02:00
David Jimenez c417de4d48 Fix TLS options typo, missing "s"
The
2017-06-22 20:31:02 -05:00
David Jimenez c0e7c61637 Use project eslint and mocha instead of global ones
As those are listed in `devDependencies`, developers should not be forced to install them globally.
2017-06-22 20:24:58 -05:00
David Jimenez 3bdb4b939c Fix LATIN1 decode test
It does not work as the latest version of the decode function
requires a Buffer, not just an array.
2017-06-22 20:13:06 -05:00
David Jimenez 50c5655ff2 Change to JSDoc type formatting {type} 2017-06-22 20:06:43 -05:00
Lillem4n fededfbfb1 Update README.md 2016-07-12 15:05:43 +02:00
lilleman bc93308a08 Version bump 2016-06-23 11:43:09 +02:00
lilleman 22c5d814e7 Merge branch 'Dexus-master' 2016-06-23 11:42:32 +02:00
lilleman 3c6f462175 Indent and minor linting tweaks to the latest merge 2016-06-23 11:42:10 +02:00
lilleman 9257174f9e Merge branch 'master' of https://github.com/Dexus/larvitsmpp into Dexus-master 2016-06-23 11:34:51 +02:00
lilleman 8c8a33ac81 Changed slow setting on some tests 2016-06-23 11:34:13 +02:00
Lillem4n f3f3ddf634 Merge pull request #15 from Dexus/fix-issue-14
Options for client should optional
2016-06-23 11:28:26 +02:00
Josef Fröhle 60cd78255f Options for client should optional
like the readme example

fixes #14
2016-06-22 22:18:36 +02:00
Josef Fröhle aaf93273c5 Add TLS Support 2016-06-22 22:13:57 +02:00
lilleman d5dbd74af2 Fixed bug on sorting concatenated messages 2016-06-08 18:40:09 +02:00
Lillem4n afeb4289c8 Merge pull request #10 from qasimakhan/master
Fix for PDU's arriving in random order.
2016-06-08 18:39:19 +02:00
lilleman 8478b166a3 Version bump 2016-06-08 11:29:50 +02:00
lilleman 9ed0f31cbc The actual eslint cleanup... 2016-06-08 11:25:51 +02:00
lilleman 46178864fc Eslint check cleanup 2016-06-08 11:25:29 +02:00
lilleman 2f4fd59156 Fixed JSON syntax error in package.json 2016-06-08 11:21:55 +02:00
lilleman aae9c8a497 Added pre-git scripts 2016-06-08 11:20:39 +02:00
lilleman 4ac751b279 Merge branch 'qasimakhan-master' 2016-06-08 11:17:56 +02:00
lilleman c0ea84e0ea Added node 6 to the travis tests 2016-06-08 11:16:19 +02:00
lilleman 4de8a71cbf Added tests for kannel large UDH test 2016-06-08 11:13:26 +02:00
lilleman d51e81ef34 Improved logging 2016-06-07 19:13:28 +02:00
lilleman b65d76a583 Improved logging 2016-06-07 19:13:16 +02:00
lilleman d25a90f6dd Updated README according to how the lib actually works 2016-06-07 19:12:55 +02:00
lilleman 96e6423a0f Raised the default socket timeout and other minor changes 2016-06-07 19:12:37 +02:00
lilleman fc629ddfdb Modifications to Qasims pull request to fix support for UDH header sizes above 1 2016-06-07 12:48:50 +02:00
lilleman 14f6fa4e5c Merge branch 'qasimakhan-master' 2016-06-07 12:37:12 +02:00
Qasim Ayyaz Khan 11512500de Fix for PDU's arriving in random order. Issue: https://github.com/larvit/larvitsmpp/issues/9 2016-06-03 10:00:30 -04:00
Qasim Ayyaz Khan 0936afbbbe Added test for PDU's arriving in random order. Issue: https://github.com/larvit/larvitsmpp/issues/9 2016-06-03 09:58:54 -04:00
Qasim Ayyaz Khan bc6b9f2146 Cleaning up the code and removing hacks for UDH headers. Also code now complies to all the test scenarios. 2016-06-03 04:46:59 -04:00
Qasim Ayyaz Khan d811dd32f2 Removing some debugging logs. 2016-06-03 00:10:05 -04:00
Qasim Ayyaz Khan 8f1c94724c Fixed extraction of UDH header from original buffer rather than from encoded message as CSMS reference / values number greater than 0x7F were not decoded properly. 2016-06-02 23:52:25 -04:00
Qasim Ayyaz Khan 7d0da6202c some fixes related to variable UDH header and Multipart SMS's
reference: https://github.com/larvit/larvitsmpp/issues/1
2016-06-02 15:18:58 -04:00
lilleman 5da55e0f52 Bumped upstream versions, minor adjustments to docs and a log message 2016-05-16 11:22:30 +02:00
lilleman f4a7c51b85 Added coverage badge n stuff 2016-04-27 09:55:55 +02:00
Lillem4n 40ae7bfe90 Merge pull request #5 from velichkov/master
Add code coverage report with Istanbul
2016-04-27 09:25:19 +02:00
Vasil Velichkov 75e18c65bc Add code coverage report with Istanbul
To generate the report run
    $npm run-script cover
2016-04-26 19:28:37 +03:00
lilleman f4ef447b56 Dependency version upgrades 2016-02-11 09:27:20 +01:00
lilleman 7614c42a54 Bumped upstream versions 2016-01-27 12:10:53 +01:00
lilleman 9aa4b6b9d9 Actually doing the dropping :) 2016-01-25 18:34:04 +01:00
lilleman 0f9971e53d Dropping support for node 0.8 2016-01-25 18:32:53 +01:00
lilleman a6998be01d Trying to please node 0.8 with clearer versions 2016-01-25 18:29:03 +01:00
lilleman 82ccb650f1 Bumped third party versions 2016-01-25 18:20:13 +01:00
lilleman 635212ca6e Fixed readme issue on npmjs 2016-01-14 13:29:17 +01:00
lilleman 1e25294095 Added test for readme example and added more logging 2016-01-14 13:19:32 +01:00
lilleman 32fc9c8063 Bumped moment version number 2016-01-13 09:17:29 +01:00
lilleman 4ddf3e798c Added dependency checking badge 2015-12-18 15:00:50 +01:00
lilleman 7ff3e93d91 Added dependency checking badge 2015-12-18 15:00:00 +01:00
lilleman 050ed2b529 Added dependency checking badge 2015-12-18 14:59:39 +01:00
lilleman bd1d6475a7 Added option to force encoding on message splitter 2015-12-14 11:49:26 +01:00
lilleman 7c0d589a07 Added solid dep versions 2015-11-29 14:44:33 +01:00
lilleman 836f4ef280 Added build status message 2015-11-15 09:45:12 +01:00
lilleman 3c3d464869 Added travis config 2015-11-15 09:41:50 +01:00
lilleman 6884c72346 Added event for login as well as original dlr pdus in the response callback 2015-06-16 21:46:15 +02:00
lilleman d7b54b6dde Added log entry for sending DLRs 2015-06-16 20:34:13 +02:00
lilleman 0228adb98c Moved smsDlr function to utils 2015-06-16 20:11:54 +02:00
lilleman 96a9e2c46a Added support for flash messages 2015-06-16 17:58:39 +02:00
lilleman 2f65fd118b Fixed negative sms IDs on failure 2015-06-16 16:56:04 +02:00
lilleman 16be9689fa Added option to let server listen to any host 2015-06-14 15:26:23 +02:00
lilleman 1e8452483f Added option to send user metadata to the server session 2015-06-14 14:55:07 +02:00
lilleman 32f6d41482 Version bumb 2015-06-14 11:30:24 +02:00
lilleman a9b6746506 Fixed bug that made session crash randomly 2015-06-14 11:30:02 +02:00
lilleman 1cfe5f7850 Fixed typo 2015-06-13 18:06:44 +02:00
lilleman 08ecfe9e1a Minor optimization to msg size counter 2015-06-13 18:05:32 +02:00
lilleman c5a4b93aee Fixed problem with UCS2 encoded long messages 2015-06-13 17:56:08 +02:00
lilleman 7c1f7ef485 Version bump 2015-06-13 16:25:58 +02:00
lilleman ad26a641c8 Added handler for socket errors 2015-06-13 16:25:40 +02:00
lilleman 93fdf8c83a Moved yet another function to pre declaration instead of inline. Also made sure references for smsGroupIds work as expected even if they are several 2015-06-13 15:58:55 +02:00
lilleman e42aa4f1e7 Minor simplification of code 2015-06-13 14:43:48 +02:00
lilleman 9b88428ebd Moved more functionality to pre declared instead of inline 2015-06-13 14:39:52 +02:00
lilleman e18a155364 Moved functions to pre declared instead of inline 2015-06-13 14:32:40 +02:00
lilleman fbb171a913 Just moved code around for some minor optimization 2015-06-11 21:38:48 +02:00
lilleman 16d3afcf9e Changed slow threshold for tests 2015-06-11 21:33:05 +02:00
lilleman 3a8bb6846c Added test for DLRs to long messages 2015-06-11 20:41:47 +02:00
lilleman 4d1d44ff55 Added tests for long messages 2015-06-04 00:27:42 +02:00
lilleman f15d89283d Added good tests 2015-06-03 18:11:29 +02:00
lilleman db766927f3 Fixed msg decoding issue and cleaning up in long messages queue 2015-06-03 16:52:15 +02:00
lilleman f157672c49 Now working with multi messages 2015-06-02 17:12:41 +02:00
lilleman 415c835574 Almost working concattening long messages 2015-05-24 15:08:54 +02:00
lilleman 80c0153b57 Monstrous amount of changes. Bad commit disciplin. 2015-05-10 23:42:11 +02:00
lilleman 2307800d22 Some restructuring etc 2015-05-10 18:23:38 +02:00
lilleman 8c6c50e8d0 Handling processing of data chunks that is not arriving in order 2015-05-10 16:53:55 +02:00
lilleman 0d5ba73e8a Added support for sending long messages via splitted short_message parameter 2015-05-10 15:35:12 +02:00
lilleman 931dac203d Merge from master 2015-05-08 21:40:45 +02:00
lilleman 994d352ada Added utils for count msg size and split msgs 2015-05-08 21:39:53 +02:00
lilleman e361ff775f Fixed issue with TLV starting with a NULL byte that follows directly upon a short_message 2015-05-03 18:33:02 +02:00
lilleman 6fa1bd319f Mostly better logging 2015-05-03 13:29:15 +02:00
lilleman 294e4ca849 Fixed issue with number values 2015-05-03 13:28:52 +02:00
lilleman 1fb075194e Some updates 2015-05-01 19:37:08 +02:00
lilleman 9548b03472 Increased support for numbers as strings 2015-05-01 17:55:31 +02:00
lilleman 11056521d9 Added more DLR status codes from SMPP 5 2015-05-01 16:37:34 +02:00
lilleman 1eb2426c20 Removed double newlines 2015-05-01 16:16:13 +02:00
lilleman 0c27fae135 Better documentation and removed old index file 2015-05-01 16:14:16 +02:00
lilleman 514ba28f0c Removed dummy data 2015-05-01 15:34:05 +02:00
lilleman 0048fd9c06 Added support for cstring numbers 2015-05-01 15:33:57 +02:00
lilleman d096f72c37 Removed stupid debug data 2015-05-01 15:24:55 +02:00
lilleman e51f4c4082 Some refactoring and increased error handling 2015-05-01 15:23:02 +02:00
lilleman df1788df0b Better robusteness for errors when writing buffers 2015-05-01 14:53:57 +02:00
lilleman c788f9ae2d More tests to test custom params on return PDUs 2015-05-01 14:32:21 +02:00
lilleman 3c9d04aacc More support for return PDUs params and tlvs and more tests 2015-05-01 14:28:30 +02:00
lilleman 04972855ed Some minor refactoring 2015-05-01 13:44:58 +02:00
lilleman 9b2e38d7c3 Finalized TLV test case 2015-05-01 13:44:43 +02:00
lilleman 93379a328d Modified tests to match new file structure 2015-05-01 12:44:41 +02:00
lilleman 24aec3073c Fixed issue with enq link trying on a closed socket 2015-05-01 12:19:47 +02:00
lilleman 5b02db7598 Sending a valid return status 2015-05-01 12:10:29 +02:00
lilleman 45b11b26da Better log message 2015-05-01 12:10:10 +02:00
lilleman c7977924e5 Splitted up module to multiple files 2015-05-01 12:02:23 +02:00
Bawer Dagdeviren 24afd01294 client sends enquire_link to keep alive 2015-04-30 16:03:14 +02:00
Bawer Dagdeviren 386712501a updated .gitignore 2015-04-08 16:25:32 +02:00
lilleman f0d428794c Latest additions to TLVs 2015-04-08 14:43:24 +02:00
lilleman 65be6142ca Updated readme 2015-04-08 10:10:18 +02:00
lilleman 7a4ed47e02 Added support for DLRs 2015-04-07 13:55:22 +02:00
lilleman 9e257e8087 Added tests for extracting TLVs from PDU 2015-04-07 00:36:28 +02:00
lilleman 4f458d081a Added support for pduToObj TLVs 2015-04-07 00:23:53 +02:00
lilleman 470e8dbd6f Better testing 2015-04-06 23:42:10 +02:00
lilleman 86051e4cd7 Never encode something in latin1 if we have the option to not to 2015-04-06 23:25:32 +02:00
lilleman 71f9deca8e Added sm_length back as parameteres as it should be 2015-04-06 23:14:41 +02:00
lilleman 4b43a92da5 Now sending SMSes works\! 2015-04-06 22:53:23 +02:00
lilleman bca6b67342 Important fixes to short_message 2015-04-06 22:47:45 +02:00
lilleman 022a12e181 Added default offset to buffer size calculation 2015-04-06 21:17:56 +02:00
lilleman 54c3386a4f Working basic sessions 2015-04-06 19:49:51 +02:00
lilleman 281b9c1d93 Modification for inherantncy for session 2015-04-06 15:37:54 +02:00
lilleman 1a01dc8249 Added more code to client() 2015-04-05 21:27:28 +02:00
lilleman ff650a5042 Moar code 2015-04-05 17:56:27 +02:00
lilleman 720276325c Fixed problem with returnPdu() not working 2015-04-05 12:59:46 +02:00
lilleman 7c7acc25bd Set ASCII as coding to test cases 2015-04-05 12:57:42 +02:00
lilleman a3f37022f3 Further work with making the server actually work 2015-04-05 01:19:20 +02:00
lilleman df5b247538 Made the options of server() optional 2015-04-04 22:52:50 +02:00
lilleman cde9631978 Added eslint for lint rules 2015-04-04 22:34:16 +02:00
lilleman 604166888b Latest additions 2015-04-04 19:46:35 +02:00
lilleman 8b8f1c6b59 Complete rewrite 2015-03-29 11:37:23 +02:00
lilleman 3cb63c23cb Added first version of client part of module 2015-02-15 00:14:08 +01:00
lilleman f81a0faec3 General improvements 2015-02-14 21:47:25 +01:00
lilleman 4539ea6d81 Added gitignore 2015-02-08 19:36:09 +01:00
lilleman 915f7cbbec First version of server 2015-02-08 19:35:51 +01:00
Lillem4n e529bb5f02 Create README.md 2015-02-08 19:04:22 +01:00
Lillem4n 37600f0372 Initial commit 2015-02-08 19:03:41 +01:00
195 changed files with 4921 additions and 77494 deletions
-13
View File
@@ -1,13 +0,0 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
[*.{yaml,yml}]
indent_size = 2
indent_style = space
+36
View File
@@ -0,0 +1,36 @@
{
"env": {
"es6": true,
"mocha": true,
"node": true
},
"rules": {
"camelcase": [0],
"comma-spacing": [2, {"before": false, "after": true}],
"eol-last": [0],
"indent": ["error", "tab"],
"key-spacing": [0],
"no-mixed-requires": [0],
"no-multi-spaces": [0],
"no-process-exit": [0],
"no-shadow": [0],
"no-underscore-dangle": [0],
"no-unused-expressions": [2],
"no-unused-vars": [2],
"no-use-before-define": [0],
"no-var": ["error"],
"one-var": [2],
"quotes": [2, "single"],
"semi": [2, "always"],
"space-infix-ops": [2],
"space-unary-ops": [1, { "words": true, "nonwords": true }],
"strict": [2, "global"],
"vars-on-top": [2],
"space-before-function-paren": ["error", {
"anonymous": "always",
"named": "never",
"asyncArrow": "ignore"
}],
"keyword-spacing": ["error"]
}
}
-40
View File
@@ -1,40 +0,0 @@
name: Mirror deletions
on:
delete:
permissions:
contents: read
jobs:
delete:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Delete the ref from GitHub once Gitea no longer has it
env:
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
MIRROR_URL: https://github.com/larvit/smpp-js.git
REF: ${{ github.event.ref }}
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
# Gitea Actions sends the full ref name here, where its webhooks send the short one.
case "$REF" in
refs/heads/*|refs/tags/*) ;;
*) echo "delete event names '$REF', not a full branch or tag ref"; exit 1 ;;
esac
# ls-remote --exit-code: 0 the ref exists, 2 it does not, anything else the remote could not be read.
on_gitea=0
git ls-remote --exit-code "$SOURCE_URL" "$REF" > /dev/null || on_gitea=$?
if [ "$on_gitea" -ne 2 ]; then exit "$on_gitea"; fi
on_github=0
git ls-remote --exit-code "$MIRROR_URL" "$REF" > /dev/null || on_github=$?
if [ "$on_github" -eq 2 ]; then exit 0; fi
if [ "$on_github" -ne 0 ]; then exit "$on_github"; fi
git init --bare --quiet mirror.git
git -C mirror.git -c credential.helper= \
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
push "$MIRROR_URL" ":$REF"
-29
View File
@@ -1,29 +0,0 @@
name: Mirror
on:
push:
schedule:
- cron: '17 3 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
push:
runs-on: ubuntu-24.04
timeout-minutes: 10
# Gitea cancels a queued job in this group when the next one arrives; only the full push may join.
concurrency:
group: mirror
steps:
- name: Push every branch and tag to GitHub, overwriting a same-named ref
env:
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
MIRROR_URL: https://github.com/larvit/smpp-js.git
SOURCE_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
git clone --bare --quiet "$SOURCE_URL" mirror.git
git -C mirror.git -c credential.helper= \
-c credential.helper='!f() { echo username=x-access-token; echo "password=$MIRROR_GITHUB_TOKEN"; }; f' \
push "$MIRROR_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
-35
View File
@@ -1,35 +0,0 @@
name: Release
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+']
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@v7.0.0
with:
cache: npm
node-version: 24.18.0
registry-url: https://registry.npmjs.org
- run: npm ci
- name: The tag must match the version being published
run: |
tagged="${GITHUB_REF_NAME#v}"
packaged="$(node -p 'require("./package.json").version')"
test "$tagged" = "$packaged" || {
echo "tag $GITHUB_REF_NAME does not match package.json $packaged"
exit 1
}
- run: npm run lint
- run: npm test
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-25
View File
@@ -1,25 +0,0 @@
name: Renovate
on:
schedule:
- cron: '43 4 * * *'
workflow_dispatch:
jobs:
renovate:
runs-on: docker-host
steps:
- name: Run Renovate against this repo
env:
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
run: |
docker run --rm \
-e GITHUB_COM_TOKEN \
-e LOG_LEVEL=info \
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.39.2
-43
View File
@@ -1,43 +0,0 @@
name: Test
on:
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@v7.0.0
with:
cache: npm
node-version: 24.18.0
- run: npm ci
- run: npm run lint
- run: npm run build
test:
runs-on: ubuntu-24.04
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
# The floor in package.json engines, every LTS above it, and current.
node: ['18', '20', '22', '24', '26']
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@v7.0.0
with:
cache: npm
node-version: ${{ matrix.node }}
- run: npm ci
# Compiled rather than type-stripped: Node 18 and 20 cannot run TypeScript directly.
- run: npm run test:compiled
+4 -4
View File
@@ -1,5 +1,5 @@
.claude
dist
dist-test
interop-tests/captures
node_modules node_modules
coverage
.idea
.tags
tmp
+14
View File
@@ -0,0 +1,14 @@
language: node_js
node_js:
- 6
- 8
- 10
script: "npm run-script cover"
after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js"
notifications:
email:
- lilleman@larvit.se
-326
View File
@@ -1,326 +0,0 @@
# AGENTS.md
## What this is
A ground-up TypeScript rewrite of `larvitsmpp` 0.4.0, published as `@larvit/smpp` 0.5.0. The branch
started from an orphan commit — no history from 0.4.0 is carried over. The 0.4.0 source is still
readable on the `v0.4.0` branch of the same repository and is the reference for protocol behaviour,
not for structure or style.
## Goals
The goals, in priority order, live in
[README.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/README.md#goals).
## Hard rules
These are not preferences. Breaking one is a defect.
1. **Nothing throws.** Every fallible function returns (or resolves to) a DTO carrying an optional
`err`. No `throw`, no rejected promises, no exceptions as control flow. Node APIs that throw are
wrapped at the boundary and converted into a result. Programmer errors (bad arguments) are
results too, wherever the types admit one: a function whose argument types are a closed set is
guarded by the compiler and stays total, which is why the encoding helpers return plainly, and
the check belongs at whichever boundary the argument arrives untyped at.
2. **Log messages are static strings.** Every dynamic value goes into the log metadata. Never
interpolate, never concatenate.
- GOOD: `log.debug('sendSms() - splitting message', { parts: msgs.length, to });`
- BANNED: `log.debug('sendSms() - splitting into ' + msgs.length + ' parts');`
3. **No `error` event.** Node makes an unhandled `error` event throw, which would break rule 1.
Sessions emit `sessionError`, servers emit `serverError`.
4. **No casts, no non-null assertions.** `as`, `as unknown as` and `!` are all banned. Parse untyped
input once through a type guard at the boundary; everything past it is typed. `noUncheckedIndexedAccess`
is on, so every lookup into a record or buffer is `T | undefined` until you handle it — that is the
point, not an obstacle to route around.
## Architecture
```
src/
index.ts Public surface. Named exports only, no default export; assembles `defs`.
log.ts SmppLog, silentLog — the default, and guardedLog(): a logger that cannot throw
message.ts Message bodies: encodeBody/decodeMessage under a data_coding, splitting, bit counting, smppDate/smppTime
options.ts SessionOptions, ReconnectOptions, their checks, and `defaults`: every default and internal cap
result.ts Result<T>, and an untyped value as error material: errorFrom(), namedValue(), quoted()
unanswered-error.ts
codec/ Bytes <-> PduObject
commands.ts The 33 commands, their ids and ordered parameter lists
constants.ts consts + constsById, optionalParamsMinVersion, and esm_class's readers: hasUdh(), messageTypeOf()
encodings.ts GSM 03.38, LATIN1, UCS2, detection, data_coding resolution, message class
errors.ts errors + errorsById (ESME_*)
pdu-framer.ts PduFramer
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
refusal.ts A PDU the codec would not read, and the answer SMPP names for it
retained-pdu.ts A PDU copied off the wire so holding it pins nothing else, and what holding it costs
tlvs.ts TLV definitions, tlvsById, the typed read and input shapes, and reading and writing a TLV stream
types.ts Wire types: int8/int16/int32/string/cstring/buffer/arrays
protocol/ What the fields mean
bind.ts Bind directions: which commands bind, what a direction carries, data_sm's stand-in, checkedBind()
concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
message-body.ts Where an inbound body is: short_message, or the message_payload TLV
message-ids.ts Message ids: the peer's notation, the <base>-<n> a segment gets, which response carries one
udh.ts User data header: its length, the concatenation fields of a long SMS and their reference
uuid.ts uuidv7() — the ids the library generates for messages
messages/ Whole messages across segments and time
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
expiring-groups.ts ExpiringGroups: the capped, weighed, expiring store DlrMerger, HeldMessages and Reassembler share
reassembly.ts Reassembler: capped, expiring multipart groups
submit.ts submitSms composition and the submitSmParams builder
session/ One socket's life, and reconnecting it
held-messages.ts HeldMessages: a message from its `sms` event to its answer, capped and expiring, one MessageHold each
idle-waiters.ts IdleWaiters: waiting for a count to fall to zero, and what is left of a budget
incoming-requests.ts IncomingRequests: every request the peer sends — messages, receipts, links, unknown commands
link-life.ts LinkLife: whether the link lives, and where a request waits for the next one
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
outgoing-requests.ts OutgoingRequests: the window, the pending map and the retry
pdu-transport.ts PduTransport: the socket a session reads complete PDUs off
pending-requests.ts PendingRequests: sequence numbers, correlation, timeout, abort
reconnect-loop.ts ReconnectLoop: backoff, retry timer, stopped-ness
send-window.ts SendWindow: the maxOutstanding semaphore
session.ts Session: the socket's life, dispatch and events, composing the rest of session/
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
client/client.ts client() -> { err, session }
server/server.ts server() -> { err, server }, server owns the listener + close()
```
Imports point one way: `codec` ← `protocol` ← `messages` ← `session` ← `client`/`server`. At the
root, `result.ts`, `log.ts` and `unanswered-error.ts` sit below `codec`, and `message.ts` and
`options.ts` with `protocol`. One edge runs up: `codec/pdu.ts` imports `encodeBody()` and
`decodeMessage()` from `message.ts`. The ways back up are the `Session` handed to `createSms()`,
`HeldMessages` and `IncomingRequests`, which call back into it, and to `OnRequest` and `onConnected`
in `options.ts`, all imported as a type only.
**Parameter order is wire order.** The key order inside `cmds.*.params` is the order the fields are
written to and read from the buffer. Never sort those alphabetically — the alphabetical-ordering
convention applies everywhere else, but here it corrupts every PDU.
## Toolchain
Run everything through the container; never invoke node or npm on the host.
```bash
docker compose run --rm node npm install
docker compose run --rm node npm test
docker compose run --rm node npm run build
```
- Source imports use `.ts` extensions; `rewriteRelativeImportExtensions` emits `.js` into `dist`.
- `erasableSyntaxOnly` is on, so no enums, no namespaces, no parameter properties. Use `as const`
objects plus union types.
- The published floor is Node 18; the dev container runs Node 24 because type stripping needs it.
- `typescript` is pinned to the 6.x line because `typescript-eslint` peer-requires `<6.1.0`. Move to
TypeScript 7 once that constraint lifts.
## Defects found in 0.4.0
Every row names what 0.4.0's own code did, so it is not rebuilt here.
[MIGRATION.md](MIGRATION.md) names what changed for a consumer, and is the only place that does.
Confirmed by reading the 0.4.0 source; each row has a regression test naming the behaviour.
| Defect | 0.4.0 behaviour |
| --- | --- |
| LATIN1 never decodes | `decodeMsg` loops `consts.ENCODING` without breaking, so `data_coding` 0x03 lands on the alias `ISO_8859_1`, which has no decoder, and silently falls back to ASCII |
| Short segments | `splitMsg` accumulates a full segment then pushes `msgPart.slice(0, -1)`, so every segment is one character short: 152 GSM characters instead of 153, 66 UCS2 instead of 67. Long messages are split into more segments than they need, and each extra segment is billed |
| DLR month off by one | `smppDate()` uses `getMonth()` (0-based) without `+1`, so January renders as `00` |
| Non-standard DLR status | Receipts emit `stat:UNDELIVERABLE`; the spec's field is 7 characters (`UNDELIV`) |
| GSM 03.38 declared as IA5 | `sendSms` resolves its encoding through `consts.ENCODING`, so a GSM body goes out under `data_coding` 0x01 — SMPP 3.4 5.2.19's IA5 (CCITT T.50), where `$` and `@` are STX and NUL |
| Flash destroys UCS2 | `flash: true` overwrites `data_coding` with 0x10, discarding the UCS2 alphabet, which needs 0x18 |
| Shared concat reference | The concatenation reference counter is a module-level global shared by every session in the process |
| `send()` never times out | Each call adds a listener keyed on the sequence number; a peer that never answers leaks it and the promise never settles |
| `tls: true` is not TLS | Constructs a bare `new tls.Socket()` with no handshake instead of `tls.connect()` |
| Alphanumeric sender TON | `sendSms` hardcodes `source_addr_ton` to 1 (international) even for alphanumeric senders, which require TON 5 |
| Text-only DLRs refused | `deliver_sm` without both `message_state` and `receipted_message_id` TLVs is rejected with `ESME_RINVTLVSTREAM`, so Kannel-style receipts are unusable |
| Unbounded reassembly | Incomplete long-SMS groups are capped by nothing and swept only when other traffic arrives, after 24 hours |
| `sar_*` segmentation unread | `session.js` reassembles on the UDH alone, so a message segmented with `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` — SMPP 3.4's other spelling, and Jasmin's documented default — reaches the application one fragment per segment |
| Dead DLR aggregation | `longSmsDlrs` is allocated to merge per-segment receipts and then never used |
| Trailing NULL truncation | `types.buffer.size()` subtracts one whenever the value's last octet is `0x00`, so the PDU is allocated one octet short while `sm_length` still reports the full length. Any UCS2 message ending in a character like U+4E00 or U+3000 goes out corrupt |
| Dormant filters | `defs.filters` is declared on commands and TLVs but never invoked anywhere |
| Unchecked reads | Wire reads index straight into the buffer, so a short or malformed PDU throws out of the codec |
| Unrangechecked writes | Integer params are handed to `writeUInt8`/`writeUInt16BE` unvalidated, so an out-of-range value throws from inside Node |
| `submit_multi` missing `sm_length` | The field is commented out of the command table, so `short_message` never round-trips for that command |
| Per-parameter defaults never applied | `calcCmdLength` reads `paramType.default` (the wire type's) rather than the parameter's, so `interface_version: 0x50` on the bind commands did nothing and every bind declared version 0x00 |
| `source_telematics_id` width | Defined as a 2-octet integer; SMPP 3.4 5.3.2.8 makes it 1 octet, unlike `dest_telematics_id`, which really is 2 |
| Binary payloads decoded as text | `data_coding` 0x02, 0x04, 0x14 and 0xF4-0xF7 are 8-bit binary and land on the GSM 03.38 table, which rewrites every octet outside it |
| Binary TLVs round-trip corrupt | `pduToObj` turns a `Buffer` TLV value into a hex string (`utils.js:307`), and `objToPdu` writes that string back as its own ASCII, so `message_payload`, `network_error_code`, `callback_num` and the rest are destroyed by any round trip |
| `ESME_RINVBCASTCHANIND` typo | Defined as `0x011`, three hex digits; the spec value is `0x0112` |
| Every response carries a message id | `session.js` builds `params = {'message_id': …}` for every response it sends, `deliver_sm_resp` included; SMPP 3.4 4.6.2 makes that field unused and NULL, and Jasmin closes the connection on one |
## GSM 7-bit is sent unpacked
Over SMPP the ESME puts one GSM character per octet in `short_message` and the SMSC packs it into
septets. The 140-octet limit applies to that packed result, not to what goes on the wire here, which
is why a concatenated GSM segment is 153 characters plus a 6-octet UDH — 159 octets in
`short_message`, and entirely correct. Do not "fix" that to 134; that number is the packed payload
size and would truncate every long GSM message by a fifth.
GSM 7-bit is the only alphabet it applies to. What each of the three is budgeted, and why, is a
decision under [The wire](docs/decisions.md#the-wire).
## Conventions
- Hard tabs. Alphabetical ordering for keys, imports and lists unless order is logic-significant.
Two deliberate exceptions: command parameters are in wire order (above), and the `errors` and TLV
tables are ordered by their numeric id so they can be diffed against the spec and gaps stay visible.
- Comments are the exception, not the default. Do not write file preambles or restate what the
code says.
- Test data uses real randomised UUID v7 values, never `aaaa-0000` placeholders.
- Fixtures that encode the wire are shared so no two files can drift on it: `test/raw-pdus.ts` builds
the octets a test writes straight to a socket, the PDUs `objToPdu()` refuses to build included. So
is the peer that answers on its own: `test/dummy-smsc.ts` is the one auto-answering SMSC, because
two copies drift in what they answer rather than in what a test asserts, and one that quietly stops
answering `enquire_link` fails the file that copied it for a reason nothing in that file names.
Reach for it where the peer's answers are not what the test is about; where they are, `smscPeer()`
in `test/session.test.ts` answers the bind and hands every other PDU to the test to answer, and
stays there because that is a different peer rather than a second copy of this one. The waiting
helpers each file carries are copies, tolerated because a wrong one fails that file's own tests and
nothing else, and a helper that only names the parameters of one `objToPdu()` call is on that same
footing — it encodes no wire fact `objToPdu()` does not already own. So is a stub standing in for a
collaborator the type system already keeps in step: `recordingDeps()` in `messaging-mode.test.ts`,
`message-class.test.ts` and `unsendable.test.ts` is one `SendSmsDeps.send` that answers nothing,
and a field added to that type fails to compile in every copy at once.
- A socket a test opens and never reads must be `resume()`d, and a `data` listener counts. An unread
socket never processes the peer's FIN, so `server.close()` hangs forever — that is a test bug, not
a library one.
- Everything a test opens gets its teardown registered as it is opened, never closed on the test's
last line: an assertion that throws skips that line, and the listener it leaves behind keeps
`node --test` alive until CI's ten-minute cap. `test/teardown.ts` covers a session, a server and a
listener; anything else takes a bare `t.after`. Its close aborts rather than drains, so a test that
fails holding the send window still ends.
- `t.after` hooks run in registration order, so registering at creation tears the outermost resource
down first. A teardown that waits on a listener must destroy that listener's own connections before
it waits, or be registered after the hook that does — `net.Server.close()` does not call back until
every connection on it is gone.
- `assert.equal` from `node:assert/strict` narrows its first argument, so a following `?.` on the
same value is flagged as unnecessary. Assert once with `assert.ok(x)` and use plain access after.
## Documentation
Each file answers one question, and a fact belongs to the file whose question it answers:
- **README.md — what you can rely on, and where this is heading.** Observable behaviour, for
someone using the package, plus the goals and the audience. It carries a reason only where the
reason changes how you would call the thing.
- **CHANGELOG.md — what changed for a consumer, per release.** Written for the public, never for the
next agent, and a line lands there as the work ships rather than at release.
- **MIGRATION.md — what a 0.4.0 consumer has to change.** Renamed and removed surface, and the
behaviour that changed on the wire.
- **AGENTS.md — what may not change, and why.** Hard rules, architecture, conventions, and an index
of the decisions. It does not restate behaviour or goals README states.
- **docs/decisions.md — what was settled, and against what.** The decisions the goals do not
already settle, each with the constraint that settled it and the alternative rejected.
- **todo.md** is a working file that sets its own rules; nothing here governs it.
A sentence living in two of them is a defect: delete the copy in the file whose question it does not
answer. The toolchain commands are the one deliberate exception — README's copy serves a contributor
who never opens this file, and this file's copy carries the constraint that nothing runs on the host.
**Write a decision down only when it cannot be put better as a goal.** A goal decides every case that
follows from it; a decision record decides one. So reach for the goal list first — sharpen a goal,
add one, or move one up the order — and write a decision only for what is left over: a choice a
competent change would otherwise re-open, that no goal implies. Give the claim, the constraint that
settled it and the alternative rejected, and nothing the code or README already says. Where a
compiler or a test already forbids the other way, it is not a decision, it is a test name. It goes in
`docs/decisions.md` with its title indexed below. Delete one once it no longer constrains anything;
this is not a changelog.
## Decisions
### [The public surface](docs/decisions.md#the-public-surface)
- `Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
public too.
- `acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.
- Both emitters re-declare their listener methods to accept a promise.
- `PduRefusedError` is exported, and `sessionError` names it in the event's type.
- `bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
`EncodingName` is what keeps an alphabet with no codec away from them.
- A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.
### [The wire](docs/decisions.md#the-wire)
- The declared interface version is an option on both `client()` and `server()`, and is not the
optional-parameter threshold.
- A peer that declared no version is pre-3.4, and `undefined` means no bind yet.
- `esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.
- A body is read from `message_payload` where `short_message` carries none, and `short_message` wins
where a peer filled both.
- A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none,
and each spelling groups in a reference space of its own.
- `sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller
writes.
- An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
it, and none goes out.
- A receipt's body is read as octets, and its own `data_coding` never says how.
- A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
with no alphabet to carry it is refused.
- A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.
- A `stat:` an operator spells outside Appendix B is read as the state it names, and the two
researched ones are `FAILED` and CM.com's `DELIVERD`.
- A transient state goes out as an intermediate delivery notification (0x20), every other state as a
delivery receipt (0x04).
- A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it
arrived.
- The optional parameters run to `command_length` exactly, and the only slack tolerated is one NULL
octet where a peer padded `short_message`.
- `smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
id.
- A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them
and 134 octets of anything it does not.
- An alphabet the caller named has to carry the message, and a time the format cannot express is
refused, both before a segment goes out.
- A string body is written in the alphabet its own `data_coding` names, and one that alphabet cannot
carry is refused by the codec — `message_payload` on the same terms as `short_message`.
- A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.
- Every text field on the wire is latin1, and what the field cannot carry is refused rather than
truncated.
- A TLV input is keyed by its tag name, or by its decimal id where the table names none, and a
`tagId` beside the key is accepted only where it agrees.
### [The session's life](docs/decisions.md#the-sessions-life)
- A close arriving after our own `unbind` is a clean unbind, not an error.
- `close` means the session is over, and a drop the loop will retry is `disconnected`.
- An answer belongs to the link the message arrived on; a receipt does not.
- `reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off
- Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.
- `reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
`client()` then resolves only once it is bound.
- `connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and
`false` is the one way to turn it off.
- A stream this library cannot frame is a dead link; one PDU it cannot parse is not.
- A deliberate shutdown drains; an unusable link and an abort do not.
- `sendSms()` puts every segment of a message on the wire together.
- Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the
application's own signal rather than the peer's answer.
- `server()` composes the application's `onRequest` after its own bind handling, and offers it every
request that handling did not answer.
- The drain waits on the messages the application holds, and `sendResp()` is what says it is done
with one.
- The drain's wait on the application ignores `shutdownTimeout: 0`.
- What the application holds unanswered is capped on constants, and a message past the cap is
refused.
- A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a delivery,
a `data_sm` by whichever it stands in for.
- A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.
- The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's gap.
- A message id base is merged at most once.
- A send that never reached the socket waits for the next link; one that did is counted, not resent.
- A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.
- One owner decides whether a link can carry a request, and a bind is what makes it one.
### [Internals and tests](docs/decisions.md#internals-and-tests)
- Locality work comes before other work until a scoring run reads 7.0.
- A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.
- The four-line abort dance is copied across `LinkLife`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.
- `SmppLog` is a five-method contract this library declares, not a dependency.
- The TLS tests build their own self-signed certificate in DER
- `src/` is grouped by layer, and imports point down the layers.
- `test/` stays flat, and a file there is named for the question it answers rather than for the
module it covers.
- CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.
- GitHub mirrors Gitea without pruning, and a ref deleted on Gitea is deleted on GitHub by a run of
its own.
-89
View File
@@ -1,89 +0,0 @@
# Changelog
## 0.6.0 (unreleased)
- `client()` now bounds each connect attempt at 10 seconds, the TLS handshake included, and reports
one that expires as an ordinary connect failure, so `reconnect` retries it on its usual backoff.
A connect previously waited the operating system out, around 130 s on Linux against a host that
drops SYNs. `connectTimeout` retunes the bound, and `connectTimeout: false` restores the old wait.
- Addresses, ids and every other text field on the wire are read and written as latin1. A
`source_addr` of `Kaffeé` previously reached the application as `Kaffei`, because the codec wrote
the octet and then masked bit 7 reading it back; `destination_addr`, `system_id`, `message_id`,
`password`, `service_type` and the C-Octet String TLVs were affected the same way. A character past
`U+00FF` in one of those fields is now refused, where it used to go out as its low octet.
**A server comparing `systemId` or `password` could be impersonated.** Masking bit 7 folded 127 of
the 255 non-zero octets onto a character a low octet also reaches, so the bind credentials your
`authenticate` received were not unique to the octets the peer sent: one refused as `admin` could
bind as `\xE1dmin` and match the same string. latin1 is one-to-one over the octets, so two
different wire values no longer arrive as one. Read 0.5.0 bind logs for a `systemId` you did not
issue.
**Check what you stored before you roll this out.** Values your application persisted under 0.5.0
were read with bit 7 masked, so an address or a `message_id` carrying an octet above `0x7F` is
spelled differently now: a stored id will not match the receipt it belongs to, and a stored address
will not match the sender it came from. Ids most SMSCs issue are digits or hex and are unaffected.
- A `U+0000` inside a C-Octet String — `source_addr`, `message_id`, `system_id` and the rest — is
refused. An Octet String carries a NULL as before.
- A non-finite number — `NaN`, `Infinity`, `-Infinity` — is refused where a text field on the wire
takes one. `sendSms({ from: NaN })` put the literal sender `NaN` on the wire and resolved as a
successful send; `message_id`, `source_addr` and the string TLVs took such a number the same way.
The call now resolves with `err` naming the field — `from: Expected a finite number, got NaN` — so
a caller that reads only `smsIds` meets a failure it has not met before. A whole number in an
address or an id still spells its digits, so `message_id: 123` is unchanged. The integer fields
name a refused `NaN` too, where the refusal used to read `null`.
- An `alert_notification` or an `outbind` from the peer is logged and left unanswered, as SMPP 3.4
gives neither a response. Each one used to emit `sessionError`, `"alert_notification" has no
response command`.
- `maxOctets` charges each held segment 1000 octets beyond its own, 300 more per TLV on it, and 300
per occurrence of a repeatable one. Segments of empty fields or thousands of empty TLVs used to
count as next to nothing, so a peer could hold far more than the cap. **Raise a `maxOctets` you
tuned low**: it now holds several times fewer segments, and an incomplete message evicted over
the cap is lost, since its segments were already answered.
- A message arriving while the application holds 1000 unanswered, or 64 MiB of them counted the way
`maxOctets` counts segments, is refused with `ESME_RTHROTTLED` (`ESME_RX_T_APPN` on a
delivery), so the peer keeps it and retries. **Call `sendResp()` on every `sms`, multipart
included**: 1000 left unanswered now stop inbound traffic for up to five minutes, where the oldest
used to be dropped with a warning.
- A `submit_sm` segment the reassembly buffer has no room for is refused with `ESME_RTHROTTLED`,
where it was `ESME_RMSGQFUL`.
- `server()` refuses a `maxOctets` below 1 or not a whole number, `Infinity` included, like its
other limits. `server({ maxOctets: 0 })` used to start and then refuse every multipart message.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
`broadcast_error_status`, the TLVs SMPP allows more than once in a PDU, keep every occurrence in
wire order. A PDU carrying two of one used to keep only the last.
**Reading one of these now needs an index.** `pduObj.tlvs.callback_num?.tagValue` is a `Buffer[]`
even where one arrived (a `number[]` for `callback_num_pres_ind` and `broadcast_error_status`), so a
`Buffer.isBuffer()` or `typeof` check written for 0.5.0 now reads it as absent. Read `tagValue[0]`
for the first occurrence. `objToPdu()`, `session.send()` and `session.sendReturn()` take
`{ tagValue: [value] }` for them and refuse a lone value before anything goes out.
- `pduObj.tlvs` and every `tlvs` input are typed per tag, as `Tlvs` and `TlvInputs`:
`receipted_message_id` reads as a `string`, `message_state` as a `number`, and a value the tag
cannot carry fails to compile. Annotate with `Tlvs` or `TlvInputs` where you wrote
`Record<string, Tlv>` or `Record<string, TlvInput>`; `TlvInput` is gone.
**A TLV input is keyed by its name, or by its decimal id where the table names none, and a `tagId`
that disagrees with its key is refused.** Write `{ 5142: { tagValue } }` for a vendor tag, not
`{ vendor: { tagId: 5142, … } }`; `{ message_state: { tagId: 5, … } }` used to go out as tag 5. A
parsed PDU's `tlvs` still relay as they are. A number for an octet TLV, vendor tags included, is
refused, where it went out as its ASCII digits.
A decimal key naming a tag the table knows, `{ 1063: … }`, is refused in favour of the name, and
so are `alert_on_msg_delivery` and `failed_broadcast_area_identifier` in favour of
`alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back under. The
two alternate names are gone from `tlvs` too, which is now typed by `TlvName`: narrow a `string` with `isTlvName()` before indexing it.
- `cmds.broadcast_sm_resp.tlvMap` is removed; nothing read it.
- The `SmsInput` type is no longer exported; nothing exported took one. Annotate with `Sms`, or a
`Pick<Sms, …>` of the fields you use.
- `session.boundAs` and `session.peerInterfaceVersion` are read-only, and `session.loggedIn` is
removed: read `session.boundAs !== undefined`. A session you wire yourself records the bind it
accepted or had accepted with `session.bound(bindType, declaredVersion)`, which returns `err` for a
bind type or version it cannot record. An assignment to either field does not compile in
TypeScript, throws a `TypeError` in strict-mode code (every ES module, and any file under
`'use strict'`), and is ignored otherwise.
## 0.5.0
The TypeScript rewrite. What a 0.4.0 consumer has to change is in
[MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md).
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-106
View File
@@ -1,106 +0,0 @@
# Migrating from larvitsmpp 0.4.0
`@larvit/smpp` succeeds [larvitsmpp](https://www.npmjs.com/package/larvitsmpp) 0.4.0. The
shape is the same, connect, send, listen for delivery reports, with callbacks replaced by promises.
## API changes
- **The package is `@larvit/smpp`** and ESM only. `require()` no longer works.
- **Callbacks are gone.** `client`, `server`, `sendSms`, `sendResp`, `sendDlr`, `unbind` and
`session.close` are promises resolving to a result with an optional `err`. Nothing rejects. Await
`close()`, or the socket outlives the call.
- **`server()` resolves once, when it is listening**, with a handle carrying `close()`, `port` and
a `session` event. It no longer calls back once per connection.
- **The id a message is answered with goes to `sendResp({ smsId })`.** `sms.smsId` is read-only: the
id the segments were answered with, the id `sendResp()` was given, or the generated UUID v7.
Assigning to it throws a `TypeError` in strict-mode code (every ES module, and any file under
`'use strict'`), and is ignored otherwise.
- **`smsIds` from `sendSms()` is `(string | undefined)[]`**, one entry per segment, positional with
`pduObjs`, `undefined` where the SMSC took the segment without naming an id.
- **`checkuserpass` is `authenticate`**, takes `{ password, session, systemId, systemType }` and
returns `false` or `{ userData }`.
- **Renamed options:** `enqLinkTiming` → `enquireLinkInterval`, server `timeout` → `idleTimeout`.
- **`larvitsmpp.utils` is gone.** Its contents are named exports: `bitCount`, `decodeMessage`,
`encodeMessage`, `objToPdu`, `pduReturn`, `pduToObj`, `smppDate`, `smppTime`, `splitMessage`. The
codec is synchronous and returns `{ err, pduObj }` / `{ err, buffer }`.
- **`pduObj.isResp()` is the standalone `isResp(pduObj)`.** `pduObj.cmdStatus` is `undefined` for
a status code the library does not know, with the raw number in `pduObj.cmdStatusId`.
- **`defs.filters` is gone.** It was declared on every command and TLV and never invoked. SMPP time
formatting, the one part worth keeping, is `smppTime`.
- **`DATAGRAM`, `FORWARD` and `STORE_FORWARD` moved from `consts.ESM_CLASS` to
`consts.MESSAGING_MODE`**, which also names `SMSC_DEFAULT`. They are bits 1-0 of `esm_class`, not
whole values of it. Read them from the new group, or pass `messagingMode` to `sendSms()`. A stale
`consts.ESM_CLASS.STORE_FORWARD` reads `undefined`, which OR-s into an `esm_class` carrying no mode.
- **A TLV is keyed by its name, or by its decimal id where the table names none**, and a `tagId`
disagreeing with its key is refused: `{ 5142: { tagValue } }`, not
`{ vendor: { tagId: 5142, tagValue } }`. A number for an octet TLV is refused; give a Buffer or a string.
Write `alert_on_message_delivery` and `broadcast_area_identifier`, the names they read back
under, for `alert_on_msg_delivery` and `failed_broadcast_area_identifier`, which are gone from
`tlvs` too.
- **`session.loggedIn` and the `loggedIn` event are gone.** `client()` resolves once bound,
`session.boundAs !== undefined` says a bind happened, and `disconnected`/`reconnected` say whether
the link is up now. A session you construct yourself records a bind with `session.bound()`.
- **The `error` event is `sessionError`**, and `serverError` on the server handle.
- **`log`** takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods instead of
a `larvitutils` one, and is silent by default: [README](README.md#logging).
- **`consts.ENCODING.ASCII` is gone**; the same entry is `consts.ENCODING.IA5`, the other name SMPP
3.4 5.2.19 gives 0x01. `dataCodingByEncoding` is the alphabet `sendSms()` writes, which is 0x00.
## Behaviour that changed on the wire
0.4.0 had protocol defects. Fixing them changes the bytes on the wire, so remove any workaround you
have for these:
- Every multipart segment was one character short (152 GSM characters instead of 153, 66 UCS2
instead of 67), so long messages were split into more segments than necessary, each one billed.
- LATIN1 (`data_coding` 0x03) was silently decoded as ASCII, corrupting the message.
- Delivery receipt dates were a month off, and the status field read `UNDELIVERABLE` where the spec
defines the 7-character `UNDELIV`.
- Every receipt went out as `esm_class` 0x04, the report of a message's final state. A receipt for a
transient state, `sendDlr('ENROUTE')`, is now marked 0x20, the intermediate delivery notification.
- `flash: true` discarded UCS2, mangling flash messages with non-GSM characters, and put the GSM
alphabet on a Latin-1 message that has no `data_coding` at all; that pair is refused now. Inbound,
only a `data_coding` of exactly 0x10 counted as flash, so a flash UCS2 message and the whole 0xF0
coding group arrived as ordinary messages.
- A GSM 03.38 message declared `data_coding` 0x01, which SMPP 3.4 5.2.19 defines as IA5, so `$` and
`@` reached a peer honouring the field as STX and NUL. It goes out as 0x00, the SMSC default
alphabet, and so does a receipt `sendDlr()` writes. Latin-1 and UCS2 stay at 0x03 and 0x08, and an
inbound 0x01 is still read as GSM 03.38.
- The multipart reference counter was shared by every session in the process.
- `tls: true` never performed a handshake, so the connection was not encrypted.
- Alphanumeric senders were sent with TON 1 (international) instead of TON 5.
- Delivery receipts carrying only the standard receipt text, with no TLVs, what Kannel and several
other SMSCs send, were rejected outright. They are parsed now.
- A message whose last octet was `0x00` was allocated one octet short while `sm_length` reported the
full length, so it went out corrupt. In UCS2 that is any message ending in a character like 一
(U+4E00), routine for CJK text.
- Every response carried a `message_id`, `deliver_sm_resp` included, where SMPP 3.4 4.6.2 makes that
field unused and NULL. Jasmin closes the connection on one. Answering an inbound message now puts
nothing in it, and `sms.smsId` is the local handle it always was.
- Binary TLVs (`message_payload`, `network_error_code`, `callback_num` and the rest) were parsed into
a hex string and written back as the ASCII of that string, so every round trip corrupted them.
They are `Buffer`s in both directions now; drop any hex encoding of your own.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
`broadcast_error_status` may repeat within a PDU, and each is an array of every occurrence in both
directions. 0.4.0 kept only the last one it read.
- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a
`data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost silently. Both reach
the application now: a receipt as `dlr`, answered for you, and a message as `sms` for you to answer.
- A long message segmented by the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum`
TLVs rather than a user data header was never reassembled, so each segment arrived as its own
message. Both spellings reassemble now.
- Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
- A PDU whose optional parameters do not end exactly on `command_length` is refused with
`ESME_RINVTLVSTREAM` and dropped, where 0.4.0 kept the TLVs it had read and ignored the octets
left over, losing the `receipted_message_id` that makes a receipt a receipt. The refusal reaches
`sessionError` as a `PduRefusedError` with `reason` `tlvs`.
- Binds declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME speaks
SMPP 3.3 or earlier, and a spec-following SMSC then withholds every optional parameter, the TLVs
delivery receipts are carried in included.
- A response reporting a failure carries no body, as the spec defines. 0.4.0 filled the body with
empty defaults, so a refused `submit_sm_resp` went out with an empty `message_id` a caller could
mistake for a real one.
- `submit_multi` was missing its `sm_length` field, so its `short_message` never round-tripped.
The corrected framing is cross-checked against [node-smpp](https://github.com/farhadi/node-smpp), an
independent implementation, in both directions and over a live session.
+144 -718
View File
@@ -1,773 +1,199 @@
# @larvit/smpp [![Build Status](https://travis-ci.org/larvit/larvitsmpp.svg)](https://travis-ci.org/larvit/larvitsmpp)
[![Dependencies](https://david-dm.org/larvit/larvitsmpp.svg)](https://david-dm.org/larvit/larvitsmpp.svg)
[![Coverage Status](https://coveralls.io/repos/larvit/larvitsmpp/badge.svg)](https://coveralls.io/github/larvit/larvitsmpp)
[![npm](https://img.shields.io/npm/v/@larvit/smpp)](https://www.npmjs.com/package/@larvit/smpp) # Larv IT SMPP
SMPP 3.4 client and server for Node.js with the session layer built in: keepalive, reconnect, send This is a simplified implementation of the SMPP protocol.
window, long messages and delivery receipts. TypeScript, ESM, no dependencies.
- **Keepalive.** `enquire_link` every 20 s on a quiet link; a peer that stops answering is dropped. ## Installation
- **Reconnect.** A dropped client link re-binds on its own, backing off from 1 s to 30 s.
- **Send window.** 10 requests in flight; further sends queue instead of overrunning the SMSC.
- **Long messages.** Split on send, reassembled on receive, in both the UDH and `sar_*` spellings.
- **Delivery receipts.** Read from TLVs or from receipt text, matched to the ids you were given.
- **Graceful shutdown.** `close()` waits for what is in flight, so neither end has to guess.
- **Never throws.** Every fallible call resolves to `{ err?, … }`.
- **Interoperable.** Tested as a client against Jasmin and SMPPSim, and as a server against Kannel,
jsmpp, Cloudhopper, python-smpplib and php-smpp:
[interop-tests/](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/interop-tests/README.md).
[Install](#install) · [Send an SMS](#send-an-sms) · [Delivery reports](#delivery-reports) ·
[Receive SMS](#receive-sms) · [Run an SMPP server](#run-an-smpp-server) · [Errors](#errors) ·
[Client options](#client-options) · [Server options](#server-options) ·
[Send options](#send-options) · [Session](#session) · [Receiving in depth](#receiving-in-depth) ·
[Server in depth](#server-in-depth) · [Logging](#logging) ·
[PDUs and the low-level API](#pdus-and-the-low-level-api) ·
[Migrating from 0.4.0](#migrating-from-larvitsmpp-040) · [Goals](#goals) ·
[Audience](#audience) · [Development](#development)
## Install
```bash ```bash
npm install @larvit/smpp npm install larvitsmpp
``` ```
Node 18 or later. ESM only, types included. ## Client
## Send an SMS ### Simplest possible
This will setup a client that connects to localhost, port 2775 without username or password and send a message.
```javascript ```javascript
import { client } from '@larvit/smpp'; const larvitsmpp = require('larvitsmpp');
const { err, session } = await client(); larvitsmpp.client(function(err, clientSession) {
if (err) throw err; clientSession.sendSms({
'from': '46701113311',
'to': '46709771337',
'message': 'Hello world'
});
await session.sendSms({ // Gracefully close connection
from: '46701113311', clientSession.unbind();
message: 'Hello world',
to: '46709771337',
});
await session.unbind();
```
Without options this binds to `localhost:2775` as a transceiver with the default credentials. A real
SMSC needs `host`, `port`, `username` and `password`: [Client options](#client-options). A message
longer than one SMS is split and sent as one concatenated message: [Send options](#send-options).
## Delivery reports
Connection parameters, a receipt per segment, and logging:
```javascript
import { Log } from '@larvit/log';
import { client } from '@larvit/smpp';
const log = new Log('debug');
const { err, session } = await client({
host: 'smpp.somewhere.com',
log,
password: 'bar',
port: 2775,
username: 'foo',
});
if (err) throw err;
session.on('dlr', dlr => {
// dlr.smsId, dlr.statusMsg, dlr.statusId
});
const { err: sendErr, smsIds } = await session.sendSms({
dlr: true,
from: '46701113311',
message: '«baff»',
to: '46709771337',
}); });
``` ```
`dlr: true` asks the SMSC to report on each segment. Match `dlr.smsId` against the `smsIds` the send ### Some connection parameters and DLR
returned. `statusMsg` is `DELIVERED`, `UNDELIVERABLE`, `EXPIRED` and so on; `intermediate` is true
for a report that is not final. The full shape, the `messageDlr` event that merges a long message's
receipts into one, and SMSCs that write ids in two notations: [Delivery receipts](#delivery-receipts).
## Receive SMS This will setup a client that connects to given host, port with username and password, send a password and retrieve a DLR and with a custom log driver, compatible with winston.
A `receiver` or `transceiver` client gets mobile-originated messages as `sms` events:
```javascript ```javascript
session.on('sms', async sms => { const larvitsmpp = require('larvitsmpp');
// sms.from, sms.to, sms.message const LUtils = require('larvitutils');
await sms.sendResp(); const lUtils = new LUtils();
}); const log = new lUtils.Log('debug');
```
Call `sendResp()` for every message, multipart included: until you do, it counts toward the bound larvitsmpp.client({
past which the peer's messages are refused. Delivery receipts reach you as `dlr` events, not here. A 'host': 'smpp.somewhere.com',
multipart message arrives reassembled and already answered segment by segment, so `sendResp()` there 'port': 2775,
puts nothing on the wire and releases it: [Receiving in depth](#receiving-in-depth). 'username': 'foo',
'password': 'bar',
'log': log
}, function(err, clientSession) {
if (err) {
throw err;
}
## Run an SMPP server clientSession.sendSms({
'from': '46701113311',
'to': '46709771337',
'message': '«baff»',
'dlr': true
}, function(err, smsId, retPduObj) {
if (err) {
throw err;
}
```javascript console.log('Return PDU object:');
import { server } from '@larvit/smpp'; console.log(retPduObj);
});
const { err, server: smpp } = await server(); clientSession.on('dlr', function(dlr, dlrPduObj) {
if (err) throw err; console.log('DLR received:');
console.log(dlr);
smpp.on('session', session => { console.log('DLR PDU object:');
session.on('sms', async sms => { console.log(dlrPduObj);
// sms.from, sms.to, sms.message, sms.dlr
await sms.sendResp(); // Gracefully close connection
clientSession.unbind();
}); });
}); });
``` ```
With authentication and delivery reports: ## Server
### Simplest possible
This will setup a password less server on localhost, port 2775 and console.log() incomming commands.
```javascript ```javascript
import { server } from '@larvit/smpp'; const larvitsmpp = require('larvitsmpp');
const { err, server: smpp } = await server({ larvitsmpp.server(function(err, serverSession) {
// Replace with your own auth. Returning an object attaches it to session.userData. if (err) {
authenticate: async ({ password, systemId }) => { throw err;
if (systemId !== 'foo' || password !== 'bar') return false; }
return { userData: { userId: 123 } }; serverSession.on('data', function(data) {
}, console.log('command: ' + data.command);
});
}); });
if (err) throw err; ```
smpp.on('session', session => { ### With auth and custom logging, returning smsId and DLR
session.on('sms', async sms => {
if (sms.answeredOnArrival) { Example code below:
await sms.sendResp(); // multipart: already answered per segment; this only releases the shutdown drain
```javascript
const larvitsmpp = require('larvitsmpp');
const LUtils = require('larvitutils');
const lUtils = new LUtils();
const log = new lUtils.Log('debug');
// This should of course be replaced with your preferred auth system
function checkuserpass(username, password, cb) {
if (username === 'foo' && password === 'bar') {
// The last parameter is just user meta data that will be attached to the session as "userData" and is optional
cb(null, true, {'username': 'foo', 'userId': 123});
} else { } else {
// no args: ESME_ROK + generated id; or sendResp({ smsId, status: 'ESME_RMSGQFUL' }) cb(null, false);
await sms.sendResp();
} }
if (sms.dlr) {
await sms.sendDlr(); // same as sms.sendDlr('DELIVERED')
}
});
});
console.log(smpp.port); // the port actually bound, useful when 0 was requested
await smpp.close(); // stop listening, then drain and close every live session
```
- `sendResp()` answers `ESME_ROK` with a generated UUID v7 as the message id.
`sendResp({ smsId, status })` names the id or refuses the message.
- `sms.dlr` is true where the sender asked for a receipt. `sendDlr()` reports `DELIVERED`,
`sendDlr('UNDELIVERABLE')` any other state: [Server in depth](#server-in-depth).
- A message that arrived in several segments was answered as they arrived, so `sendResp()` there
takes no `smsId` or refusing `status`. `sms.answeredOnArrival` says which case you are in.
## Errors
Nothing throws. Every fallible call returns a result with an optional `err`:
```javascript
const { err, session } = await client({ host: 'smpp.somewhere.com' });
if (err) return;
const { err: sendErr, smsIds } = await session.sendSms({ from, message, to });
```
Failures on a live session arrive as `sessionError` events, on a server handle as `serverError`.
Neither is named `error`, because Node throws on an unhandled `error` event.
`sessionError` carries three kinds of failure:
| Kind | Type | |
| --- | --- | --- |
| A PDU the peer sent that the codec could not read. The link stays up; only that PDU is lost. | `PduRefusedError` | Count it. |
| A concatenated message given up on before it was whole. Its segments were answered, so the peer will not resend, and no `sms` fired for it. | `Error` | Count it as lost traffic. |
| The session or socket failing, or a hook or listener that threw or rejected. | `Error` | Alert. |
The last two are told apart by message text only, so this alerts on both:
```javascript
import { PduRefusedError } from '@larvit/smpp';
session.on('sessionError', err => {
if (err instanceof PduRefusedError) {
log.warn('the peer sent a PDU that could not be read', {
cmdName: err.header.cmdName ?? err.header.cmdId,
reason: err.reason,
});
return;
}
log.error('a session failure or lost traffic', { message: err.message });
});
```
- `reason` is `command`, `body` or `tlvs`: the part the codec stopped at.
- `header` is the 16 octets that did parse: `cmdId`, `cmdLength`, `cmdName`, `cmdStatusId` and
`seqNr`. `cmdName` is undefined for a command id this library does not know. `PduHeader` is its type.
- A refused request is answered with the status SMPP names for it. A refused response is answered
with nothing, and settles the request it named as `unanswered`.
- A refused inbound `deliver_sm` is lost traffic: a message or receipt that never arrives as `sms`
or `dlr`. A refused response is reported twice, as the `err` of the `sendSms()` or `send()`
waiting on it and here.
- A `PduRefusedError` is always a PDU that arrived. What this library refuses to build or send (an
alphabet, a time, a body its `data_coding` cannot carry) is a plain `Error` in the call's result.
## Client options
All optional. Timeouts and delays are milliseconds.
| Option | Default | |
| --- | --- | --- |
| `host`, `port` | `localhost`, `2775` | Where to connect. |
| `username`, `password` | `user`, `pass` | Bind credentials: `system_id` and `password`. |
| `bindType` | `transceiver` | `transceiver`, `transmitter` or `receiver`. |
| `interfaceVersion` | `0x34` | The SMPP version declared at bind. `0x50` for an SMSC that requires SMPP 5.0. |
| `systemType`, `addressRange`, `addrTon`, `addrNpi` | `''`, `''`, `0`, `0` | The remaining bind fields, for operators that require them. |
| `tls` | `false` | `true` for defaults, or a `tls.ConnectionOptions` object for a private CA or a client certificate. |
| `connectTimeout` | `10000` | Give up on **each connect attempt** the SMSC never completes, the TLS handshake included, and report it as an ordinary connect failure, which `reconnect` then retries. It bounds the socket and the handshake — never the `client()` call, and never the wait for the bind response, which is `responseTimeout`. `false` waits the operating system out instead, around 130 s on Linux; `0` is refused. |
| `enquireLinkInterval` | `20000` | Interval between `enquire_link` on a quiet link. |
| `idleTimeout` | `2 × enquireLinkInterval` | Give up on a link the peer has stopped answering, and re-bind unless `reconnect` is `false`. |
| `responseTimeout` | `30000` | How long to wait for a response, and how long a send with no link waits for the next one. `0` waits forever. |
| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for requests already sent and messages not yet answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires, so both at `0` never ends. The messages then fall back to `responseTimeout`, or to its default where that is `0` too. |
| `maxOutstanding` | `10` | Requests on the wire at once; further sends queue. |
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place: `{ receipt: 'decimal', submitResp: 'hex' }`. Only where the two disagree: [Delivery receipts](#delivery-receipts). |
| `reconnect` | on | `{ minDelay, maxDelay }` retunes the backoff; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect too. |
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods: [Logging](#logging). |
| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
**Reconnect.** After a drop, an idle timeout, or a stream the library cannot frame, the client
reopens the socket and re-binds, doubling the delay from `minDelay` (1 s) to `maxDelay` (30 s), and
starts over at `minDelay` once a link has lasted `maxDelay`.
`reconnect: { fromStart: true }` puts the first connect and bind through the same loop, a bind the
SMSC refuses included, so a client started while its SMSC is down keeps retrying. `client()` then
resolves once bound, and only an aborted `signal` ends the wait. That signal also closes the session
once bound, so write a deadline as an `AbortController` you stop arming when `client()` returns,
not as `AbortSignal.timeout(ms)`.
## Server options
All optional. Timeouts are milliseconds.
| Option | Default | |
| --- | --- | --- |
| `host`, `port` | all interfaces, `2775` | Where to listen. `port: 0` takes any free port; `smpp.port` says which. |
| `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. |
| `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends: [Server in depth](#server-in-depth). |
| `systemId` | `''` | The SMSC identity returned in the bind response. |
| `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. Optional parameters are sent to a peer from `0x34` up, whatever this is set to. |
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. A bare `true` is refused. |
| `idleTimeout` | `40000` | Drop a peer that has been silent this long. |
| `maxReassembly` | `1000` | Incomplete multipart messages held per session. |
| `maxOctets` | `67108864` | Roughly the memory incomplete multipart messages may hold per session: each held segment counts its octets plus 1000, 300 more per TLV on it, and 300 per occurrence of a repeatable one. |
| `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. |
| `responseTimeout`, `shutdownTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | |
## Send options
```javascript
await session.sendSms({
dlr: true, // ask for a delivery report
destinationAddrNpi: 0, // override the numbering plan of the recipient
destinationAddrTon: 1,
encoding: 'UCS2', // override the automatic choice
flash: false,
from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1
maxSegments: 10, // refuse a longer message instead of sending it
message: 'Hello world',
messagingMode: 'SMSC_DEFAULT', // or DATAGRAM or STORE_FORWARD
scheduleDeliveryTime: new Date(Date.now() + 3600_000),
sourceAddrNpi: 0, // override the numbering plan of the sender
sourceAddrTon: 5,
to: '46709771337',
validityPeriod: 3600, // seconds, or a Date
}, { signal }); // optional per-call AbortSignal
```
**Addresses.** `sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address
and 1 for a numeric one; the NPI fields default to 0. An address is latin1, so `é` is one octet on
the wire and an address you received always sends back. One outside `/^[\u0001-\u00FF]*$/` is
refused, naming the character and its index — strip or transliterate it first. An SMSC may still
refuse a non-ASCII sender of its own accord, which reaches you as a refusal such as
`ESME_RINVSRCADR`.
**Encoding.**
| `encoding` | Alphabet | Characters per SMS | Per segment of a long message |
| --- | --- | --- | --- |
| `ASCII` | GSM 03.38 7-bit | 160 | 153 |
| `LATIN1` | ISO 8859-1 | 140 | 134 |
| `UCS2` | UCS-2 | 70 | 67 |
- Omitted: `ASCII` where the message fits GSM 7-bit, otherwise `UCS2`. `LATIN1` only when named.
Any other name is refused.
- GSM extension characters (`{}[]\~^|€` and form feed) count as two, as does a character outside
the basic multilingual plane in `UCS2`.
- An alphabet you name has to carry every character, or the send is refused before anything goes
out, naming the character, its code point and its index. Detection never refuses.
- `LATIN1` carries every octet, so `buffer.toString('latin1')` reaches the SMSC byte for byte, under
`data_coding` 0x03, which declares Latin-1 text. To declare 8-bit binary, hand `session.send()` a
`Buffer` body and the `data_coding` you want: [PDUs and the low-level API](#pdus-and-the-low-level-api).
- `consts.ENCODING` is the low-level `data_coding` table, not this option's list.
**Long messages.**
```javascript
const { err, pduObjs, smsIds, unanswered } = await session.sendSms({ from, message, to });
```
- One id per segment. `smsIds` is positional with `pduObjs`, and an entry is `undefined` where the
SMSC took the segment without naming an id; some name one for the first segment only. No receipt
ever carries an empty id, so an unnamed entry matches nothing.
- `err` is set when the SMSC refuses a segment, naming the status, or leaves one unanswered. Every
segment goes out together, so `pduObjs` and `smsIds` then hold only the accepted segments, in send
order: enough to reconcile a later receipt, not enough to resend the rest. Treat a partial failure
as a failed message; its accepted segments report through `dlr` alone.
- `unanswered` counts segments that went out and were never answered. The SMSC may have taken each
and lost only the response, so a message with `unanswered` above zero cannot be resent without
risking a duplicate.
- More than 255 segments is refused before anything is sent, since the concatenation header numbers
segments in one octet. `maxSegments` lowers that ceiling; most handsets and SMSCs stop well short.
**Flash.** `flash: true` asks for GSM 03.38 message class 0, shown on arrival instead of stored. It
travels in `data_coding` beside the alphabet, so a flash UCS2 message stays UCS2. `flash` with
`encoding: 'LATIN1'` is refused: no `data_coding` carries both.
**Messaging mode.** `messagingMode` names the `esm_class` mode: `SMSC_DEFAULT`, which is what an
omitted option sends, `DATAGRAM` or `STORE_FORWARD`. Every segment of a long message also carries the
user data header indicator, so `STORE_FORWARD` on one sends `esm_class` 0x43. `DATAGRAM` with
`dlr: true` is refused, since datagram mode has no delivery reports. Transaction mode
(`consts.MESSAGING_MODE.FORWARD`) exists only on `data_sm`, which is never sent, and is refused too.
**Times.** `scheduleDeliveryTime` and `validityPeriod` take a `Date`, a number of seconds, or a stamp
you formatted. Refused before anything goes out: an invalid `Date`, `NaN`, `Infinity`, a negative
count, and a count past 99 days 23:59:59, since a count in seconds is spelled in days and below.
Name a later instant as a `Date`, which goes out absolute.
**What gets checked.** The library checks what it composes: an address you gave as `from` or `to`,
an alphabet or a time you named, a string body under a `data_coding` you named. What you formed
yourself, a `Buffer` body or a stamp you formatted, passes through as written, except that a text
field is still checked: [PDUs and the low-level API](#pdus-and-the-low-level-api). The same rule
holds for `session.send()`.
## Session
### Events
| Event | Fires when |
| --- | --- |
| `sms` | An SMS arrives, reassembled if it was multipart. Carries `sendResp()`, `sendDlr()` and `smsId`. |
| `dlr` | A delivery report arrives, one per segment, with its PDU as the second argument: [Delivery receipts](#delivery-receipts). |
| `messageDlr` | Every segment of a long message sent with `dlr: true` has a final report: [Delivery receipts](#delivery-receipts). |
| `close` | The session is over and nothing will bring the link back. Fires once, whether you closed it or the link failed for good. |
| `disconnected` | The link dropped and the reconnect loop will retry. Do not open a replacement client: this session comes back on its own, and `reconnected` says when. Fires again for each attempt that reconnects and then fails, so it is not one-to-one with `reconnected`. |
| `reconnected` | The client re-bound after a drop. |
| `sessionError` | Something failed on a live session, a PDU the codec refused included: [Errors](#errors). |
| `data` | Raw bytes arrived on the socket. |
| `incomingPdu` | A complete PDU arrived, as a buffer. |
| `incomingPduObj` | The same PDU, parsed into an object. |
### Methods
`sendSms()`, `send()`, `sendReturn()`, `unbind()` and `close()`.
**Shutdown.** `close()` and `unbind()` both:
1. Refuse further sends. `sendDlr()` is the one send let past, when issued straight after
`sendResp()`; await anything in between and it races the shutdown like any other send.
2. Wait up to `shutdownTimeout` for the requests already sent, and for every `sms` the application
has not answered. That wait ends when `sendResp()` puts the response on the wire (or, for a
message answered on arrival, when it is called at all), or when every listener that took the
message has failed. Answering through `sendReturn()` instead leaves the wait running.
3. Tear down what is left, resolving to an `err` that says what was lost.
A message left unanswered for five minutes is no longer waited for.
`close({ signal })` cuts the wait short. `unbind()` takes no signal, and waits a further
`responseTimeout` for its own response.
**Sends and the link.**
- A send issued while the link is down waits for the reconnect and goes out once the new link is
bound, up to `responseTimeout`, after which it gives up having sent nothing.
- A request already on the wire when the link drops, when the peer fails to answer in time, or when
you abort it, fails and counts in `unanswered`: the SMSC may have taken it and lost only the
response.
- With `reconnect: false` a drop ends the session, and every send after it is refused.
- `sms.sendResp()` on a message whose link dropped writes nothing and returns `err`, since a response
carries the sequence number of the link it arrived on. `sms.sendDlr()` still goes out on the new
link.
- `responseTimeout` bounds the wait for a link and the wait for an answer separately, and the wait
for a `maxOutstanding` slot is unbounded, so it is not a deadline. For a deadline pass
`{ signal: AbortSignal.timeout(ms) }`: it cuts all three waits short, and a send it stops before
anything reached the socket adds nothing to `unanswered`. A message with more segments than slots
goes out a slot at a time, so a deadline that expires mid-message is how you get a partial failure.
- There is no throughput limit. An operator's rate limit is per account, across every process bound
to it, so enforce it outside this library. `ESME_RTHROTTLED` reaches you as a send's `err`.
**Raw commands.** `send()` reaches all 33 SMPP commands, not just the four the session handles itself:
```javascript
const { err, pduObj } = await session.send({
cmdName: 'query_sm',
params: { message_id: smsId },
});
```
**The peer.**
- `acceptsOptionalParams()`: whether the peer declared SMPP 3.4 or later, the version from which
optional parameters may be sent to it. The library's own senders check it before attaching a TLV;
a `send()` you build is passed through as written, so check it yourself.
- `peerInterfaceVersion`: the version the peer declared, `0x00` if none, `undefined` before any bind.
- `bindAllows(cmdName)` and `boundAs`: what the bind direction carries: [Bind direction](#bind-direction).
- `boundAs` and `peerInterfaceVersion` are read-only, and hold through a reconnect's gap until the
link binds again.
- `bound(bindType, declaredVersion)`: how a session you construct yourself records a bind, whichever
end accepted it, on every link it binds. `bindType` is `receiver`, `transceiver` or `transmitter`;
`declaredVersion` is 0-255, or `undefined` where the peer declared none. Anything else returns `err`
and records nothing.
- An ESME wired by hand sends its own `bind_<bindType>` through `session.send()`, after it is
constructed and again in `reconnect.onConnected`, and records each accepted one with
`session.bound(bindType, pduObj.tlvs.sc_interface_version?.tagValue)`, where `bindType` is the one
it sent and `pduObj` the `bind_resp` that `send()` resolved with.
## Receiving in depth
- **Multipart.** Segments tied together by a user data header, or by the `sar_msg_ref_num`,
`sar_total_segments` and `sar_segment_seqnum` TLVs, reassemble into one `sms` alike. A PDU carrying
both is read from the header. The two reference numbers are separate counters: the same number in
each is two messages.
- **Answered on arrival.** Each segment was answered as it landed, before you see the message:
[Server in depth](#server-in-depth).
- **Unanswered messages.** While a session holds 1000 messages you have not called `sendResp()` on,
or 64 MiB of them counted the way `maxOctets` counts segments, every new message and segment is
refused so the peer retries it: `ESME_RTHROTTLED` on a submission, `ESME_RX_T_APPN` on a delivery.
No `sms` fires. Reaching the bound logs one `warn`, and the first message accepted once both are
down to half one `info`. A message left five minutes is dropped from the count with a `warn`; a
later `sendResp()` still answers it. None of the three is an option.
- **Where the body is.** A body in the `message_payload` TLV, SMPP's way of carrying up to 64 KB and
the only place a `data_sm` has, reads exactly like one in `short_message`, concatenated messages
and receipts included. A PDU filling both is read from `short_message`.
- **`data_sm`.** A client reads an inbound `data_sm` as a delivery: a message arrives as `sms`, a
receipt as `dlr`. A `server()` session reads it as a submission and always emits `sms`. Either way
it is answered `data_sm_resp`.
- **Flash.** `sms.flash` is true where `data_coding` carries GSM 03.38 message class 0, in every
coding group that carries one: `0x10`, `0x18`, `0x50` and `0xF0` alike. Classes 1 to 3 name where
the handset stores the message and are not flash.
- **Binary.** A message whose `data_coding` says 8-bit binary arrives as Latin-1:
`Buffer.from(sms.message, 'latin1')` gives the original octets.
### Delivery receipts
Receipts travel on the same command as messages but reach you as `dlr`, one per segment. What marks
one: `esm_class`; where that names no type, a `receipted_message_id` TLV; failing both, `id:` and
`stat:` in the body. The body is read as text whatever `data_coding` the receipt declares, since
SMSCs commonly copy the reported message's onto it. An intermediate delivery notification is a report
too, never an inbound message.
| `Dlr` field | |
| --- | --- |
| `smsId` | The id reported on. `undefined` where the receipt carries no readable id. |
| `statusMsg` | `DELIVERED`, `UNDELIVERABLE`, `EXPIRED`, `REJECTED`, `DELETED`, `ACCEPTED`, `UNKNOWN`, `ENROUTE`, `SCHEDULED` or `SKIPPED`. `stat:FAILED`, which several operators write and SMPP does not define, reads as `UNDELIVERABLE`. |
| `statusId` | The numeric `message_state`. Where the peer sent one this library cannot name, its raw value, with `statusMsg` from the body or `UNKNOWN`. |
| `intermediate` | The report is not final: marked an intermediate notification, or reporting `ENROUTE` or `SCHEDULED`. |
| `receipt` | The receipt text parsed: `id`, `sub`, `dlvrd`, `submitDate`, `doneDate`, `stat` as the SMSC wrote it, `err` and `text`. |
| `doneDate`, `errorCode` | The `done date:` field as a `Date`, and the `err:` field. |
**Matching a receipt to a send** means comparing `dlr.smsId` with the `smsIds` from `sendSms()`.
Some SMSCs write the two in different notations, a hex `message_id` on the `submit_sm_resp` and a
decimal `id:` in the receipt, or one of them zero-padded, and the comparison then matches nothing.
Name each notation and both are read into plain decimal:
```javascript
const { err, session } = await client({ smsIdFormat: { receipt: 'decimal', submitResp: 'hex' } });
```
`receipt` is the notation of the body's `id:`; `submitResp` that of the `message_id` in
`submit_sm_resp` and of the `receipted_message_id` TLV, which carries that same id. An id that is not
a number in the notation named is left as it arrived. The PDUs carry what the peer wrote either way:
`pduObjs` from the send, and the `dlr` event's second argument.
**`messageDlr`** fires once every segment of a long message sent with `dlr: true` has a final report,
carrying the worst status of the segments and each of them under `segments`. An `intermediate`
report never counts. Merging needs the SMSC to number its segment ids `<base>-<n>`, this library's
own server's convention; an SMSC that hands out unrelated ids per segment never fires it. A base is
merged once: a later message the SMSC gives the same ids is reported through `dlr` alone, and an
earlier one still collecting loses its merged report. A send that returned an `err` never fires `messageDlr`,
even where the SMSC took some of its segments; their receipts still arrive as `dlr`.
## Server in depth
**Multipart is answered on arrival.** Each segment is answered as it lands, because a relaying SMSC
will not send the next until the last is answered. The answer is `ESME_ROK`, unless the segment
numbers itself into no message this session can join, which refuses it, or the reassembly buffer or
the unanswered messages are at their bound, which asks the SMSC to keep it and try again.
`sms.answeredOnArrival` says whether the message you hold was answered that way; a segment count
cannot, since a peer may number a message one part of one.
- The id was fixed with the first segment, so `sendResp()` there puts nothing on the wire and
releases the message, and returns `err` for an `smsId` or a refusing `status`.
- `sms.smsId` is the base. `sendDlr()` names `<smsId>-1`, `<smsId>-2` and so on: the ids the
`submit_sm` responses carried.
- A `deliver_sm` is answered with no id at all, since SMPP marks that field unused, so an inbound
message's base is a handle of your own only.
**Refusing a request** for a reason in the request rather than the message (a full queue, an unknown
recipient, an unauthorised sender) has to land before a segment is answered. `onRequest` runs on
every request a bound peer sends, before reassembly and before the `sms` event:
```javascript
import { isCommand, server } from '@larvit/smpp';
const knownRecipients = new Set(['46709771337']);
const { err } = await server({
onRequest: async (session, pduObj) => {
if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) {
return false;
}
await session.sendReturn(pduObj, 'ESME_RINVDSTADR');
return true;
},
});
if (err) throw err;
```
- Return `true`: the hook answered the PDU and the library leaves it alone. `false`: the built-in
handling runs.
- Every segment of a long message is a request of its own, so the hook sees each one.
- No bind reaches it, nor anything a peer sends before one: `server()` answers those and runs
`authenticate` itself.
- A hook that throws or rejects reaches `sessionError`, and nothing is written for that request;
the peer's own response timeout settles it. `authenticate` fails the same way, leaving the bind
unanswered.
- `enquire_link` and `unbind` reach the hook too, and an unanswered `enquire_link` has the peer drop
the link. Guard on the command name, as above, and a failing hook costs only its own request.
- A `Session` you construct yourself takes the same hook as a session option, and that is where a
peer's bind gets accepted, since a hand-wired session has no bind handling of its own: call
`session.bound(pduObj.cmdName.slice('bind_'.length), pduObj.params.interface_version)` before
answering it, and refuse the bind with `ESME_RBINDFAIL` where that returns `err`.
**`sendDlr()`** takes `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
`ACCEPTED`, `UNKNOWN`, `REJECTED` or `SKIPPED`. The first two go out as intermediate delivery
notifications (`esm_class` 0x20), the rest as delivery receipts (0x04).
### Bind direction
The three bind types are honoured in both directions, whichever end of the link the session is:
- `session.sendSms()` on a receiver-bound session, and `sms.sendDlr()` to a transmitter-bound peer,
return `err` before anything reaches the wire.
- A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one,
is answered `ESME_RINVBNDSTS`.
- `data_sm` carries a message either way, so which end the session is decides: a client refuses one
on a transmitter bind, a `server()` session on a receiver bind. `bindAllows('data_sm')` answers for
the inbound direction. A `Session` you construct yourself is the ESME end, as `client()` builds;
a hand-wired SMSC sets `session.linkEnd = 'smsc'`, as `server()` does.
- `transceiver`, the default, carries both. `session.send()` is a passthrough and is not checked.
## Logging
`log` takes any object with `debug`, `error`, `info`, `verbose` and `warn` methods, each
`(msg: string, metadata?: Record<string, boolean | number | string>) => void`. Message strings are
static; every dynamic value is in the metadata, so entries group by message.
[`@larvit/log`](https://www.npmjs.com/package/@larvit/log) implements it as it stands:
```javascript
import { Log } from '@larvit/log';
import { client } from '@larvit/smpp';
const { err, session } = await client({ log: new Log('debug') });
```
So does an object of your own:
```javascript
const log = {
debug: () => undefined,
error: (msg, metadata) => { console.error(msg, metadata); },
info: (msg, metadata) => { console.info(msg, metadata); },
verbose: () => undefined,
warn: (msg, metadata) => { console.warn(msg, metadata); },
};
```
`SmppLog` is the type.
## PDUs and the low-level API
The codec is exported, synchronous, and never throws:
```javascript
import { isCommand, objToPdu, pduToObj } from '@larvit/smpp';
const { err, pduObj } = pduToObj(buffer);
if (err) return;
if (isCommand(pduObj, 'submit_sm')) {
pduObj.params.destination_addr; // typed as a string
} }
larvitsmpp.server({
'checkuserpass': checkuserpass,
'log': log
}, function(err, serverSession) {
if (err) {
throw err;
}
// Incoming SMS!
serverSession.on('sms', function(sms) {
// It is important to run the sms.resp() since this is a part of the protocol
sms.sendResp(
// Status code
// Default is ESME_ROK == no error
// See SMPP spec for all available status codes
// For example: ESME_RINVDSTADR == "Invalid destination address".
'ESME_ROK'
);
// Oh, the sms sender wants a dlr (delivery report), send it!
if (sms.dlr === true) {
sms.sendDlr(); // Equalent to sms.sendDlr('DELIVERED');
// To send a negative delivery report for example do:
sms.sendDlr('UNDELIVERABLE');
// Possible values are:
// SCHEDULED
// ENROUTE
// DELIVERED <-- Default
// EXPIRED
// DELETED
// UNDELIVERABLE
// ACCEPTED
// UNKNOWN
// REJECTED
// SKIPPED
}
});
});
``` ```
**Reading.** ## Session Events
- `params.short_message` is decoded with the PDU's own `data_coding`; `shortMessageOctets` is that #### connect
field as it arrived. Neither holds a body carried in the `message_payload` TLV, which a `data_sm`
always uses.
- `messageOctets(pduObj)` is the one answer to which of the two the peer used, undecoded.
`decodeMessage(octets, pduObj.params.data_coding, pduObj.params.esm_class)` turns them into text
and hands back the UDH where the PDU carries one.
- `concatOf(pduObj)`: the `part`, `total` and `reference` a PDU declares and the `spelling` that
carried them, `'udh'` or `'sar'`, or `undefined` for a whole message.
- `pduObj.tlvs` is typed per tag, as `Tlvs`: `receipted_message_id` a string, `message_state` a
number, `message_payload` a `Buffer`. A tag the table does not define is a `Buffer` keyed by its
decimal id, `tlvs['5142']`.
- `callback_num`, `callback_num_atag`, `callback_num_pres_ind`, `broadcast_area_identifier` and
`broadcast_error_status` may repeat in one PDU, so each reads as an array of every occurrence in wire
order: `number[]` for `callback_num_pres_ind` and `broadcast_error_status`, `Buffer[]` for the rest.
A `broadcast_sm_resp`'s `failed_broadcast_area_identifier` reads as `broadcast_area_identifier`.
- `messageClassOf(dataCoding)`: `0` for the flash class, `1`, `2` and `3` for the ME-, SIM- and
TE-specific ones, `undefined` where that `data_coding`'s coding group carries no class.
**Building.** Triggered when the socket is connected to a client. This is server specific.
- A string `short_message` or `message_payload` is encoded in the alphabet the PDU's `data_coding` #### data
names, detected from the text where you name none. One that alphabet cannot carry is refused,
naming the character, its code point and where it is.
- Every text field is latin1: addresses, `system_id`, `message_id`, `service_type` and the C-Octet
String TLVs. A character past `U+00FF` is refused, as is a `U+0000` in a C-Octet String.
- `tlvs` is keyed and typed like `pduObj.tlvs`, as `TlvInputs`, so a parsed PDU's `tlvs` relay as
they are: `{ message_state: { tagValue: 2 } }`, or `{ 5142: { tagValue: octets } }` for a tag the
table does not define. Any other key, a decimal id the table names, a `tagId` disagreeing with its
key, and a number for an octet TLV are refused.
- The five repeatable TLVs take an array, written as one TLV per element; a lone value or an empty
array is refused.
- A `Buffer` goes out exactly as given under any `data_coding`: binary payloads, hand-built user
data headers, deliberately malformed bodies.
- `session.send()` and `session.sendReturn()` build through the same codec and refuse the same bodies.
- `unencodable(message, encoding)`: `{ char, index }` for the first character an alphabet cannot
carry, `undefined` where it carries them all. The check `sendSms()` makes before encoding.
- `dataCodingByEncoding[encoding]`: the `data_coding` this library writes each alphabet under, which
is what to put beside octets from `encodeMessage()`.
- `smppTime.encode(value)` returns `{ err, text }` for a `validity_period` or
`schedule_delivery_time`; `smppTime.decode(text)` returns `{ err, date }`.
**Everything exported.** Triggered when data is comming in on the socket.
| | | #### close
| --- | --- |
| Sessions | `client`, `server`, `Session`, `SmppServer` |
| Codec | `pduToObj`, `objToPdu`, `pduReturn`, `isCommand`, `isResp`, `PduFramer`, `PduRefusedError`, `maxPduLength`, `maxSeqNr` |
| Messages | `encodeMessage`, `decodeMessage`, `splitMessage`, `bitCount`, `messageOctets`, `concatOf`, `concatInfo`, `detect`, `unencodable`, `messageClassOf`, `dataCodingByEncoding`, `encodingByDataCoding` |
| Receipts | `dlrFromPdu`, `parseReceipt`, `receiptCodes` |
| Time and ids | `smppDate`, `smppTime`, `uuidv7` |
| Spec tables | `cmds`, `consts`, `encodings`, `errors`, `tlvs`, `types`, the `cmdsById`, `constsById`, `errorsById` and `tlvsById` maps, and all of them grouped as `defs`. `isCommandName`, `isErrorName`, `isEncodingName`, `isTlvName`, `commandNameById` and `errorNameById` narrow a value into them. |
| Types | Every option, result, event payload and table entry has a named type: `ClientOptions`, `ServerOptions`, `SendSmsOptions`, `SendSmsResult`, `Sms`, `Dlr`, `MessageDlr`, `Receipt`, `PduObject`, `PduHeader`, `SmppLog`, `Result` and the rest in `dist/index.d.ts`. |
## What changed per release Triggered when the socket is closed.
See [CHANGELOG.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/CHANGELOG.md). #### error
## Migrating from larvitsmpp 0.4.0 Generic error event.
See [MIGRATION.md](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/MIGRATION.md). #### sms
## Goals Incoming SMS.
In priority order, and the order is the point: where two of them pull against each other, the earlier #### incomingPdu
one wins. They do not override the [hard rules](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#hard-rules).
1. **Correct on the wire.** SMPP 3.4 as SMSCs actually run it. Every other goal yields to this one; Incoming PDU.
the [defect table](https://gitea.larvit.se/larvit/smpp-js/src/branch/main/AGENTS.md#defects-found-in-040) is what the alternative costs.
2. **Never give the application a wrong answer about what happened.** An outcome we cannot determine
is reported as undetermined rather than guessed; a report the peer marked as not final settles
nothing, so nothing the library concludes may rest on one; a request the peer may already have
taken is never re-sent on the library's own initiative; work the peer has no reason to send again
is not dropped; a call that reports a message as sent asserts that the wire carried what the caller
wrote, so a value we cannot send as given is refused before anything goes out; each message gets
one outcome as a whole, so a send that fails is that outcome and no merged report follows it.
3. **Strict in what we send, generous in what we read.** The library's own senders follow 3.4, and
the codec parses whatever arrives. Where the letter of the spec would discard traffic a real SMSC
sends, keep the traffic.
4. **A peer an operator never has to complain about.** No bind flooding, nothing a bind direction
forbids, no optional parameters to a peer that declared none, nothing held without a bound.
5. **The session layer is in here, and its defaults are what most applications should run.**
Keepalive, reconnect, the send window, reassembly and receipt correlation. What the network says
about a message the application sent reaches it as a report rather than as an inbound message, and
says whether it is final, so nothing has to read the PDU to tell those apart. An option retunes a
default or opts out of it; an option does not switch on the thing the caller obviously wanted.
6. **Fast enough that this library is never the bottleneck.** Throughput over a few sessions rather
than many idle ones, which is why this exists on Node at all. On four cores or more, one bound
session sustains at least 5,000 `submit_sm`/s at a send window of 1, 20,000 at the default window
of 10, and 30,000 at 50 or above, and asking for delivery receipts costs nothing measurable.
Memory is bounded per session, never per process. [benchmarks/](benchmarks/README.md) is how those
floors are checked; every release re-measures them and asks what it would take to go faster.
7. **Configurable and extendable, never at the defaults' expense.** Where an application needs other
than the default and cannot build it from what is exported — a rate limit counted per PDU, an
alphabet, a receipt format — it gets an option or a hook rather than a fork. A call that passes no
options stays exactly as easy and as safe, and a hook is a seam the library calls, never a way into
its internals.
8. **A small, stable public surface over reshapeable internals.** A new option has to beat "the
application can do this itself", and has to keep a promise this library can verify. The low-level
surface is a passthrough: policy binds what the library composes, never what the caller wrote.
9. **State wider than one session goes through one store.** A pool of sessions, a limit shared
between processes, and what has to survive a restart — receipts still awaited, a message half
reassembled — are held through a store interface and never beside it. Without a store the
application supplies, that state is in memory and ends with the process, and the defaults need
none. The interface carries the library's own versioned records, never an internal shape handed
to the application to persist. Coordinating processes any other way is declined without a fresh
argument each time.
10. **It builds, tests and runs the same everywhere.** Container-only toolchain, no runtime
dependencies, the Node 18 floor verified in CI, every README example executed
by the suite.
## Audience #### incomingPduObj
Who depends on this library, and what they may rely on. Incoming PDU Object. Same as incomingPdu, but it have been converted into an object instead of a buffer.
- **The public npm audience.** Only what `src/index.ts` exports is public; everything behind it is ## Session commands
reshaped freely.
- **Node 18 and newer, ESM only, no runtime dependencies.**
- **Real SMSCs and ESMEs as operators actually run them**, not a reference implementation. Jasmin,
SMPPSim, Kannel, jsmpp, Cloudhopper, python-smpplib and php-smpp are the interop targets, and what
they do in practice outranks what the specification says they should do.
- **The SMSC operator on the far end**, who never sees this API but carries what it does to their
link. A peer they have to complain about is a defect however well the library reads.
- **The developer building the SMPP edge of something else.** What binds to `server()` in practice is
an aggregator's customer-facing edge, a bridge putting SMPP in front of a modern transport, or a
test double standing in for an SMSC. This is not a store-and-forward SMSC and will not become one:
spooling, scheduling, retry policy and billing belong to whatever this is the edge of. State shared
between instances is for goal 9's store, which has not shipped: today every session keeps its own,
in memory.
- **Pre-1.0, so the minor is the breaking unit** and a patch never breaks.
Personas this README serves, in order: ### send
1. The application developer sending or receiving SMS on the defaults, who should need no options. Send a PDU to the remote.
2. The application developer who needs one thing retuned — an alphabet, a rate limit, a receipt
format — through an option or a hook rather than a fork.
3. The developer migrating from `larvitsmpp` 0.4.0.
## Development
Everything runs in the container; nothing is installed on the host.
```bash
docker compose run --rm node npm install
docker compose run --rm node npm test # lint, typecheck and tests
docker compose run --rm node npm run build
docker compose run --rm node npm run test:compiled # what CI runs on older Node versions
```
Tests are TypeScript and run directly under Node's type stripping, so there is no build step in the
development loop. CI compiles them and runs them on Node 18, every LTS above it, and current.
## License
MIT
-103
View File
@@ -1,103 +0,0 @@
# Benchmarks
What this library sustains, and against what. Run them before setting or changing a scale goal.
```bash
docker compose run --rm node node benchmarks/run.ts # this library, both ends
COUNT=100000 docker compose run --rm node node benchmarks/run.ts # longer, steadier
```
`run.ts` spawns `smsc-sink.ts` — a server that answers every `submit_sm` `ESME_ROK` and stores
nothing — and drives it with `submit-load.ts` at four window sizes. Point `submit-load.ts` at any
SMSC to compare:
```bash
docker compose -f compose.yaml -f interop-tests/compose.jasmin.yaml run --rm node \
node benchmarks/submit-load.ts --host=jasmin --port=2775 --username=esme1 --password=esme1pw \
--count=20000 --concurrency=50
```
`GATE=1` fails the run when a window falls under goal 6's floor, and refuses to judge at all on
fewer than four cores.
**A short run measures the JIT, not the library.** At 2,000 messages per point the same build
reported 16k/s where 100,000 messages reported 40k/s. Give each point several seconds.
**Cores matter, though the library is single-threaded.** The run is two Node processes, and past
them V8 marks and compiles on threads of its own while the kernel carries loopback TCP. Window 200,
100,000 messages:
| Cores | msgs/s |
| --- | --- |
| 1 | 20,476 |
| 2 | 29,603 |
| 4 | 32,869 |
| 8 | 40,046 |
A window of 1 is unmoved by any of it (7,280–8,659 throughout) because it waits on the round trip
rather than the CPU. The windowed figures are for the pair: one process alone reaches about half.
## Results, 2026-09-20
Single host, 8 cores, Node 24.18.0 in the project container, loopback. Client and SMSC are separate
processes competing for the same CPUs, so these are a floor for split hosts.
`maxOutstanding` is the send window, and it is the setting that matters most:
| Window | msgs/s | with `dlr: true` |
| --- | --- | --- |
| 1 | 7,385 | 7,289 |
| 10 (default) | 25,358 | 24,282 |
| 50 | 37,125 | 37,502 |
| 200 | 40,046 | 39,730 |
Requesting delivery receipts costs nothing measurable at any window. A window of 1 — one request in
flight at a time — costs 5x, which is the round trip rather than the codec.
Against real peers, same driver, window 50, 20,000 messages:
| SMSC | msgs/s | failed |
| --- | --- | --- |
| this library's sink | 37,125 | 0 |
| Jasmin 0.11.0 | 2,207 | 0 |
| SMPPSim 2.6.11 | — | 19,000 of 20,000 |
## Against the other client libraries
Same sink, same 100,000 single-segment messages, same host. This is the comparison that means
something: every client is measured pushing into *our* server, so the server's work is common to all
three and only the client differs.
```bash
docker compose -f compose.yaml -f benchmarks/compose.jsmpp.yaml up -d --build
docker compose -f compose.yaml -f benchmarks/compose.jsmpp.yaml run --rm node \
node benchmarks/peer-load.ts --driver=http://jsmpp:8080 --count=100000 --concurrency=50
```
| Window | this library | jsmpp 3.0.3 | Cloudhopper 5.0.10 |
| --- | --- | --- | --- |
| 10 | 25,358 | 30,771 | 27,945 |
| 50 | 38,675 | 40,934 | 32,384 |
| 200 | 40,046 | 42,105 | 25,497 |
**We are slowest at the default window**, which is the setting most callers will ever run — 25,358
against jsmpp's 30,771. That is the throughput work worth doing, and it is worth doing there.
Two things the table does not show. This library does it on one event loop where both Java peers
spend one OS thread per in-flight request, which is why Cloudhopper falls off at 200 threads and we
do not. And all three are pushing into the same Node sink, whose own cost is in every number, so the
differences between clients are compressed rather than exaggerated here.
Kannel is absent deliberately: it is a gateway rather than a client library, wired here as an ESME
that forwards from its own spool, so loading it would measure its HTTP frontend and queue rather
than an SMPP client. The number would not belong in this table.
Jasmin routes and persists where the sink does neither, so the gap is not an efficiency ratio
between two comparable things — what it establishes is that this library is not the bottleneck
against a production SMSC, by more than an order of magnitude. SMPPSim's store fills at roughly a
thousand messages and it then refuses the rest, so it cannot be loaded; that is a property of the
simulator, not a result.
`smppload`, the one purpose-built SMPP load generator among the peers, is blocked by a bind defect
of its own ([findings/07-load.md](../interop-tests/findings/07-load.md)), so no third-party load
tool drives these numbers.
-25
View File
@@ -1,25 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
# The peer dials the host it was given at build time, "node", so the sink answers under that name.
services:
cloudhopper:
build: ./interop-tests/peers/cloudhopper
image: interop-cloudhopper-load:5.0.10-ae6485a
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
node:
command: ["node", "benchmarks/smsc-sink.ts"]
environment:
NPM_CONFIG_CACHE: /tmp/npm-cache
PORT: "2775"
-25
View File
@@ -1,25 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
# The peer dials the host it was given at build time, "node", so the sink answers under that name.
services:
jsmpp:
build: ./interop-tests/peers/jsmpp
image: interop-jsmpp-load:3.0.3-a24db96
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
node:
command: ["node", "benchmarks/smsc-sink.ts"]
environment:
NPM_CONFIG_CACHE: /tmp/npm-cache
PORT: "2775"
-33
View File
@@ -1,33 +0,0 @@
/**
* Drives a peer's HTTP control surface through the same load the local driver runs, so the number
* that comes back is that library's own rate against our sink rather than ours against theirs.
*/
function arg(name: string, fallback: string): string {
const found = process.argv.find(one => one.startsWith(`--${name}=`));
return found === undefined ? fallback : found.slice(name.length + 3);
}
const driver = arg('driver', 'http://jsmpp:8080');
const count = arg('count', '20000');
const concurrency = arg('concurrency', '50');
async function call(path: string): Promise<unknown> {
const response = await fetch(`${driver}${path}`);
return response.json();
}
// Cloudhopper's window defaults to 1 and is set at bind, so threads alone would serialise it.
const bound = await call(`/bind?systemId=bench&password=benchpw&windowSize=${concurrency}`);
if (typeof bound !== 'object' || bound === null || !('ok' in bound) || bound.ok !== true) {
process.stdout.write(`${JSON.stringify({ bind: bound })}\n`);
process.exit(1);
}
const loaded = await call(`/load?count=${count}&concurrency=${concurrency}`);
process.stdout.write(`${JSON.stringify(loaded)}\n`);
await call('/unbind');
-95
View File
@@ -1,95 +0,0 @@
import { availableParallelism } from 'node:os';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { createInterface } from 'node:readline';
/** Goal 6's floors, per send window. Set GATE=1 to fail the run instead of only reporting. */
const floors: Record<number, number> = { 1: 5000, 10: 20_000, 50: 30_000, 200: 30_000 };
/** Below this, client and sink contend for one core and the windowed floors are unreachable. */
const gateCores = 4;
/**
* Node rather than Python, unlike the repo's other standalone scripts: it spawns the two processes
* it measures, and they must run on the same runtime this library is measured under.
*/
const concurrencies = [1, 10, 50, 200];
const count = Number(process.env.COUNT ?? 5000);
function node(script: string, args: string[] = []) {
return spawn(process.execPath, [`${import.meta.dirname}/${script}`, ...args], {
stdio: ['ignore', 'pipe', 'inherit'],
});
}
async function firstLine(stream: NodeJS.ReadableStream): Promise<string> {
for await (const line of createInterface({ input: stream })) {
return line;
}
return '';
}
const sink = node('smsc-sink.ts');
const listening: unknown = JSON.parse(await firstLine(sink.stdout));
if (typeof listening !== 'object' || listening === null || !('port' in listening)) {
throw new Error('the sink did not report a port');
}
const port = String(listening.port);
const rows: Record<string, unknown>[] = [];
for (const dlr of [false, true]) {
for (const concurrency of concurrencies) {
const load = node('submit-load.ts', [
`--port=${port}`,
`--count=${String(count)}`,
`--concurrency=${String(concurrency)}`,
...(dlr ? ['--dlr'] : []),
]);
const reported: unknown = JSON.parse(await firstLine(load.stdout));
await once(load, 'exit');
if (typeof reported === 'object' && reported !== null) rows.push({ ...reported });
}
}
sink.kill('SIGTERM');
await once(sink, 'exit');
process.stdout.write(`\n${'dlr'.padEnd(6)}${'window'.padEnd(9)}${'msgs/s'.padEnd(10)}seconds\n`);
for (const row of rows) {
const dlr = String(row.dlr).padEnd(6);
const window = String(row.concurrency).padEnd(9);
const rate = String(row.perSecond).padEnd(10);
process.stdout.write(`${dlr}${window}${rate}${String(row.seconds)}\n`);
}
const cores = availableParallelism();
process.stdout.write(`\n${String(cores)} cores\n`);
if (process.env.GATE !== '1') process.exit(0);
if (cores < gateCores) {
process.stdout.write(`refusing to gate on ${String(cores)} cores; goal 6 states four or more\n`);
process.exit(1);
}
const short = rows.filter(row => {
const floor = floors[Number(row.concurrency)];
return floor !== undefined && Number(row.perSecond) < floor;
});
for (const row of short) {
const floor = String(floors[Number(row.concurrency)]);
process.stdout.write(`below goal 6: window ${String(row.concurrency)} ran ${String(row.perSecond)}/s, floor is ${floor}\n`);
}
process.exit(short.length === 0 ? 0 : 1);
-33
View File
@@ -1,33 +0,0 @@
import { server } from '../src/server/server.ts';
/**
* Answers every submit_sm ESME_ROK and does nothing else, so a measurement against it reads this
* library's own ceiling rather than an SMSC's storage. Prints the bound port on stdout, then waits.
*/
const port = Number(process.env.PORT ?? 0);
const { err, server: smpp } = await server({ port });
if (err) {
process.stderr.write(`sink failed to listen: ${err.message}\n`);
process.exit(1);
}
let answered = 0;
smpp.on('session', session => {
session.on('sms', async sms => {
answered++;
await sms.sendResp();
});
});
smpp.on('serverError', reason => {
process.stderr.write(`sink serverError: ${reason.message}\n`);
});
process.stdout.write(`${JSON.stringify({ port: smpp.port })}\n`);
process.on('SIGTERM', () => {
process.stderr.write(`sink answered ${String(answered)}\n`);
void smpp.close({ signal: AbortSignal.abort() }).then(() => process.exit(0));
});
-72
View File
@@ -1,72 +0,0 @@
import { client } from '../src/client/client.ts';
/**
* Pushes `count` single-segment messages and reports what the wire carried per second. Keeps
* `concurrency` sends in flight so the send window, not the caller, is what bounds the rate.
*/
function arg(name: string, fallback: string): string {
const found = process.argv.find(one => one.startsWith(`--${name}=`));
return found === undefined ? fallback : found.slice(name.length + 3);
}
const host = arg('host', '127.0.0.1');
const port = Number(arg('port', '2775'));
const count = Number(arg('count', '2000'));
const concurrency = Number(arg('concurrency', '20'));
const message = arg('message', 'benchmark');
const dlr = process.argv.includes('--dlr');
const username = arg('username', 'user');
const password = arg('password', 'pass');
const { err, session } = await client({
host,
maxOutstanding: concurrency,
password,
port,
responseTimeout: 60_000,
username,
});
if (err) {
process.stdout.write(`${JSON.stringify({ error: err.message })}\n`);
process.exit(1);
}
// Narrowing from the guard above does not reach into worker(), which runs after it.
const bound = session;
let issued = 0;
let failed = 0;
let unanswered = 0;
async function worker(): Promise<void> {
while (issued < count) {
issued++;
const sent = await bound.sendSms({ dlr, from: 'BENCH', message, to: '46709771337' });
if (sent.err) failed++;
unanswered += sent.unanswered;
}
}
const started = process.hrtime.bigint();
await Promise.all(Array.from({ length: concurrency }, () => worker()));
const seconds = Number(process.hrtime.bigint() - started) / 1e9;
process.stdout.write(`${JSON.stringify({
concurrency,
count,
dlr,
failed,
perSecond: Math.round(count / seconds),
seconds: Number(seconds.toFixed(3)),
unanswered,
})}\n`);
await bound.close({ signal: AbortSignal.abort() });
process.exit(0);
-10
View File
@@ -1,10 +0,0 @@
services:
node:
image: node:24.18.0-bookworm-slim
init: true
user: "1000:1000"
working_dir: /app
volumes:
- .:/app
environment:
NPM_CONFIG_CACHE: /tmp/npm-cache
-315
View File
@@ -1,315 +0,0 @@
# Board on the three rewrite plans
## Junior seat
**Junior seat** (about 2 years of TypeScript, no SMPP)
My read of main: `link-life.ts` has 7 predicates over a phase plus a separate `stopped` flag, and the initial `'up'` breaks its own rule. `expiring-groups.ts` has a header that says what it does *not* enforce. `session.ts` routes `captureRejections` for `sms` into `incoming.listenerRejected`. The README's "Receive SMS" section tells me to call `sendResp()` on a multipart message that "puts nothing on the wire". I agree with 6 overall and Locality 5.
## Plan 1: folders follow the README
1. **Would it help? Marginal, close to yes.** Folders named after the README sections are the first layout I could find things in without asking. The problem is answering: I can answer with `sendResp()`, by returning, or by throwing, and `answeredOnArrival` is still there. That is D's "answered in three places" again, only now behind `OwedAnswer`.
2. **Predicted scores**
- Nav 7: README section maps to folder.
- Loc 6: the answer path spans `receive-message.ts`, `running-handlers.ts`, `owed-answer.ts` and `sms.ts`.
- Shape 6: the `AnswerPort`/`SendPort` ports are indirection I have to learn.
- Self 6: citations get summaries, but the glossary sits in the README, and lessons.md says that did not lift juniors.
- Overall 6.
3. **Where I would still get stuck:** `receiving/running-handlers.ts` together with `session/owed-answer.ts`. What happens when `sendResp()` is called and the handler then throws?
4. **Wrong or vague**
- The "two SmsSenders" question is left open until chunk 6.
- `client()` returns a `ClientSession` but still calls it `session`, and `sock`/`sendReturn` move to `session.link`. I would not know which object to listen on.
- Keeping `sendResp()` next to "return answers" gives two ways to answer.
- The no-handler refusal turns a transceiver that never wanted inbound messages into an endless SMSC retry loop.
## Plan 2: state ownership first
1. **Would it help? Marginal.** It keeps one public `Session` over many internal `Link`s, which brings back the thing F removed: the lifecycle is still split over two fields. It adds `LinkOwner`, five callbacks implemented in another file, which is E's lesson again. `session.ts` stays the hub for handover, link-wait, reconnect and the merger.
2. **Predicted scores**
- Nav 6: the Session versus Link vocabulary. `sms.ts` sits in `session/` while `handlers.ts` sits in `link/`.
- Loc 6: there is one answer ledger, but handover is split across `adopt`/`onGone` and the callbacks.
- Shape 6: four request lanes are still four rules.
- Self 6: `wire/fields.ts` helps, but the glossary sits away from the code in `docs/`.
- Overall 6.
3. **Where I would still get stuck:** `session/session.ts` handover together with `link/link.ts`'s `LinkOwner`.
4. **Wrong or vague**
- This plan has the smallest API break of the three, and that is the best part for an application developer.
- The `sendDlr`-before-answer error will surprise anyone writing a test SMSC.
- The reassembly store refusing its own entry is a behaviour change with no decision written yet.
## Plan 3: the newcomer's lens
1. **Would it help? Yes.** `protocol/vocabulary.ts` puts TSDoc on hover, and all bit masks stay inside `protocol/`. `data-coding.ts` becomes a table with a "why" column, and a test fails on any bare citation. That attacks the Self-sufficiency 5 that capped juniors. `Reply` as a return value makes "one answer" a type.
2. **Predicted scores**
- Nav 7: protocol, codec, messages, session is a reading order.
- Loc 6: `client/client.ts` gathers the current session, the merge, the reference counter, the retry loop, re-emitting, `fromStart` and abort.
- Shape 7: forward-only session, a store that refuses, `Reply` as the one path.
- Self 7: definitions at the point of use.
- Overall 6, one refactor short of 7.
3. **Where I would still get stuck:** the request loop and event re-emitting in `client/client.ts`. The plan itself predicts this.
4. **Wrong or vague**
- It has six breaks. A1 renames `{ session }` to `{ client }`, which breaks every README example for no comprehension gain beyond F, which scored the same as main.
- A3's `Reply.dlr` is a second way to send a receipt next to `sendDlr()`.
- A4 removes `sendReturn()`, the escape hatch for hand-wired users.
- `handlerTimeout` appears in `handlers.ts` but is missing from the API table.
- Refuse-not-evict can block multipart traffic for `reassemblyTimeout`, which regresses goal 4.
- `waiting.ts` extracts the abort dance, contradicting a recorded decision without saying so.
- A6 (`'ASCII'` to `'GSM7'`) is justified for me as a reader, but it is optional.
## Verdict
- **Ranking:** Plan 3, Plan 1, Plan 2.
- **Can the best reach 7?** Plausibly, about a coin flip. The single change that most raises its odds: move the retry loop and the link-wait out of `client/client.ts` into their own file, `client/next-link.ts` as in Plan 1, so `SmppClient` only holds and forwards. Dropping A1 and A3 would also stop the application-developer complaints from costing Shape.
- **Structure or intrinsic difficulty?** Mostly structure. Each round moved the hardness around while the SMPP-to-plain-types translation was never in one place. The truly intrinsic parts are small and can be localized: per-segment answers on arrival, the drain's two budgets, and retrying only what was never written. For a junior, the rest of the ceiling was missing domain vocabulary. That is a structural choice about where meaning lives, not a property of the problem.
PLAN1 helps=marginal nav=7 loc=6 shape=6 self=6 overall=6
PLAN2 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6
PLAN3 helps=yes nav=7 loc=6 shape=7 self=7 overall=6
## Mid seat
**Mid seat (5 years TypeScript, never read the SMPP spec)**
My view of main today: 6. I can find my way around the flat `src/`, and `session.ts` is readable at the top. `LinkLife` is where I stall. It has a phase, a `stopped` flag, seven predicates and an initial `'up'` that breaks its own rule. `ExpiringGroups` is the other place: its doc comment says it enforces neither of its own bounds, yet `weigh()` can evict the key the caller is writing. The `captureRejections` routing to `incoming.listenerRejected` also takes me three files to follow.
## Plan 1: README verbs as folders, a one-socket Session, and ClientSession
1. **Helps? Marginal, leaning yes.** Folders that mirror the README's table of contents are what I would guess first. `owed-answer.ts` gives "one answer per request" a single writer, and `BoundedStore` stops evicting the caller's own entry. But the one-socket split is F's, which already scored 6.25, and the plan leaves its sharpest follow-up open: which object owns `SmsSender`, to be settled "at chunk 6".
2. **Predicted scores:**
- Navigation 7: the folder names are the README sections.
- Locality 6: an inbound message still crosses `dispatch.ts`, `receive-message.ts`, `running-handlers.ts`, `owed-answer.ts` and `sms.ts`.
- Shape 6: `ClientSession` forwards events, and there are two `SmsSender`s and two ports.
- Self-sufficiency 7: the README glossary, citations with their summaries, and `Invariant:` paragraphs.
- Overall 6.
3. **What would still defeat me:** `client/client.ts`. A `ClientSession` that re-emits the current `Session`'s events, answers `boundAs` through the reconnect gap, and holds a merger fed from the link's `dlr`. It is the "which object do I listen on" problem moved up one layer.
4. **Wrong or vague:**
- `client()` still resolves `{ session }`, but the value is a `ClientSession` and `session.link` is the real `Session`. That name misleads an application developer.
- `sendResp()` is kept, and returning from the handler also answers. That is two ways to answer the same message, and `answeredOnArrival` stays as a third concept.
- `limits/` is an abstraction name I would not look in for the send window.
- The dual `SmsSender` is undecided.
## Plan 2: state ownership, with a Link inside the public Session
1. **Helps? Marginal.** The ownership rules are right: one writer, a lifetime `AbortController`, and "emit last". `wire/fields.ts` naming the bit masks helps me more than any glossary. But `Session` still spans many links, which is the unit that capped round two, now split across `session.ts` and `link.ts`. They are joined by `LinkOwner`, a five-callback interface, which is exactly what defeated E.
2. **Predicted scores:**
- Navigation 6: `link/` against `session/` is a distinction I must learn before I can place `handlers.ts`, `sms.ts` or `send-sms.ts`.
- Locality 6: the answering path is in two adjacent files, which is good, but a link going down spans `Link`, `LinkOwner`, `adopt`/`onGone`, `link-wait.ts` and `outbound.ts`.
- Shape 6: four lanes in one table are still four rules. `sock` means "the current or last Link's", and `boundAs` is read "from the last bound Link".
- Self-sufficiency 6: the glossary lives in `docs/glossary.md`, away from the code, and the lessons say an off-code glossary did not lift juniors.
- Overall 6.
3. **What would still defeat me:** `session/session.ts` `adopt`/`onGone`, with `session/outbound.ts`'s retry loop that goes back to `boundLink()`. That is the reconnect lifecycle again, under new names.
4. **Wrong or vague:**
- It keeps the reconnecting `Session` to avoid F's rename. That preserves the exact structure every round-two seat named.
- Changing `sendReturn()` to return `err` in two new cases is a silent behaviour change on an existing call.
- The rule "a `sendDlr()` before the answer returns `err`" breaks test-double servers that report immediately, and the plan admits it.
- The five callbacks and the lane table are not specified.
## Plan 3: a `protocol/` translation layer, `Reply` return values, and SmppClient
1. **Helps? Yes.** This is the only plan aimed at my actual cost: the bit masks and SMPP terms, translated once in `protocol/` into typed plain values, with definitions I see on hover in the editor. A citation test enforces that every spec reference carries its sentence. `requests-in.ts replyFor()` is a pure function returning a `Reply`, which makes "one answer per request" a type instead of an agreement between files. Its `Session` is one socket with one forward-only state field.
2. **Predicted scores:**
- Navigation 7: `codec`, `protocol`, `messages`, `session`, `client`, `server` read in order. The weak spots are `retained.ts` and `bounded-store.ts` under `messages/`.
- Locality 7: a reply is decided in one function and written in one place, and data_coding is one table.
- Shape 6: two `sendSms` surfaces, `Reply.dlr` beside `sendDlr()`, and a `SmppClient` hub.
- Self-sufficiency 7: definitions next to their use, and every citation says what it cites.
- Overall 7.
3. **What would still defeat me:** `client/client.ts`. It holds the current session, the receipt merge, the concatenation reference counter, the retry-if-unwritten request loop, event re-emission, `close`/`unbind`, and `fromStart` plus abort. That is seven responsibilities in one file, and the plan itself predicts it becomes the next hardest unit.
4. **Wrong or vague:**
- Six breaking changes is more than "a minimum". A6 (`'ASCII'` renamed to `'GSM7'`) breaks every caller that set an encoding. It is justified by one-spelling-per-goal but not required for comprehension, since the internal rename already gets that gain.
- A3's `Reply.dlr` is a second way to send a receipt.
- Refusing new segments when the reassembly store is full, instead of evicting the oldest group, lets abandoned groups block all multipart traffic until `reassemblyTimeout`. That hurts an operator (goal 4), and the plan does not weigh it against the goal 2 gain.
- Removing `sendReturn()` takes away an escape hatch for low-level users without saying what replaces it beyond "return a `Reply`".
- `retained.ts` sits in `messages/` only because the stores use it. That is proximity, not a real seam.
## Verdict
**Ranking:** Plan 3, then Plan 1, then Plan 2.
**Can the best reach 7 overall?** Plausibly on my seat. A panel mean of 7 is less likely, because the architect seat will cite the `SmppClient` hub and the number of API breaks. The single change that would most raise its odds is to split `SmppClient`'s request loop into its own file (`client/next-link.ts`, as in Plan 1), owning waiting-for-a-bound-session and retry-only-unwritten with its invariant. `client.ts` then only composes, and the predicted next hardest unit never forms.
**Structure or intrinsic difficulty?** Mostly structure, and specifically the contract that structure was built around. Each round, the named hardest unit was self-inflicted rather than SMPP:
- the held-message timing contract;
- a reconnecting session with duplicated stop flags;
- a store that does not enforce its own bounds;
- answering enforced jointly by two files.
When the contract changed, the unit moved, and none of the ones named since are protocol facts. The intrinsic part — multipart answered on arrival, the drain's two budgets, retrying only what never reached the socket, and data_coding groups — is real. But it is a handful of localized items, which puts the ceiling near 7–8, not 6. The earlier redesigns stalled because each one left a different piece of shared, unowned state behind.
PLAN1 helps=marginal nav=7 loc=6 shape=6 self=7 overall=6
PLAN2 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6
PLAN3 helps=yes nav=7 loc=7 shape=6 self=7 overall=7
## Senior seat
Plan 3 is the only one I expect to reach 7. Plan 1 is a marginal gain and plan 2 is roughly main plus renames. My seat most likely already gave main its 7, so a rewrite has to beat 7 on this seat to count as help here.
**How hard main is today, from my seat.** `link-life.ts` shows lesson 3 exactly. It has a 4-value phase starting at `'up'`, a separate `stopped`, and seven predicates. Its `generation()` counter exists only so a reader can tell a link has gone. `session.ts` (453 lines) wires ten collaborators. Its `captureRejectionSymbol` override reaches into `incoming.listenerRejected`. The layout is flat and named well, so Navigation is fine. The cost is in Locality: to know whether a send is safe you need `LinkLife`, `OutgoingRequests` and `Session` open together.
---
## Plan 1: verb folders, one-socket `Session`, `ClientSession` above it, `onSms` plus `sendResp` kept
**1. Would it help? Marginal.**
- The one-way `Session`, the reconnect union state, `BoundedStore` enforcing its own bounds and one defaults file all remove units the panels named.
- It adds new sources of confusion in their place:
- `client()` still returns a field called `session` that is a `ClientSession`, and `session.link` is a `Session`. Two names now lie.
- Answering has two spellings. The handler can call `sendResp()` or just return, and the return path does "answer `ESME_ROK` if not answered". That is D's "answered in several places" again, now as a getter over OwedAnswers.
- The answer path spans five files in two folders: `dispatch.ts`, `owed-answer.ts`, `receive-message.ts`, `running-handlers.ts` and `sms.ts`.
**2. Predicted scores**
- **Navigation 7.** The README table of contents mirrors the folder listing, which makes the layout easy to find your way around. `limits/` is the one abstract name.
- **Locality 6.** The one-answer invariant has one writer, but you cannot understand it without the other four files. `AnswerPort` and `SendPort` add a hop.
- **Shape 6.** `ClientSession` forwards a long event list. Whether there is one `SmsSender` or two is left open.
- **Self-sufficiency 7.** Every citation gets a summary, the README gets a glossary, and there is a data-coding table.
- **Overall 7**, one point above the lowest dimension (Locality 6), which is the most the rule allows. It is no better than main on this seat.
**3. Where I would still get stuck.** `client/client.ts` together with `client/next-link.ts`: `ClientSession` forwarding events from whichever `Session` is current, plus answering `boundAs` through the reconnect gap. Second place: `session/owed-answer.ts` together with `receiving/running-handlers.ts`.
**4. Wrong or vague**
- `SmsSender` is left as "pick one at chunk 6". That is an ownership question, and the plan's own principle says ownership comes first.
- Returning `ClientSession` under the name `session` is a public API that misleads the developer about what they hold. Either rename it honestly, as plan 3 does, or keep a single `Session`.
- Keeping `sendResp()` alongside the implicit return answer gives two ways to answer, against the one-spelling rule.
- `linkEnd` becoming readonly is justified.
---
## Plan 2: state ownership, a `Link` per socket behind the unchanged public `Session`
**1. Would it help? Marginal.**
- The strongest ownership rules of the three: one `AbortController` as the single writer of stoppedness, and "a transition finishes before anyone hears about it".
- The smallest API break, with a single answering spelling (`onSms` returning `{smsId}`/`{status}`) and a ledger that refuses a second answer.
- But its structure is draft A (a `Link` object per socket) plus E's weakness (`LinkOwner`, five callbacks implemented in another file). Lesson 3 of the round-three notes already says E's state machine stayed hard because meaning was split across callbacks.
- `session.ts` stays the hub: life, current link, handover, link-wait, window, reconnect, merger.
- `outbound.ts` keeps four "lanes", which were already cited in round 2.
**2. Predicted scores**
- **Navigation 6.** Readers must learn the Session/Link split. `link/` holds handlers and reassembly, which a reader would not look for there.
- **Locality 6.** A handover is `adopt`/`onGone` in `session.ts` plus the `LinkOwner` callbacks in `link.ts`. Each transition's meaning is split across two files.
- **Shape 6.** Four lanes, a hub `Session`, and a new "refuse own entry" eviction policy.
- **Self-sufficiency 7.** `wire/fields.ts` names the bit masks, and every citation gets its sentence.
- **Overall 6.**
**3. Where I would still get stuck.** `session/session.ts` (`adopt`/`onGone` and the link handover) read against `link/link.ts`'s `LinkOwner`. Second place: the lane table in `session/outbound.ts`.
**4. Wrong or vague**
- `sendDlr()` returning `err` before the answer is a trap for test SMSCs that report immediately. The plan admits this and gives only a README pattern as the fix.
- The own-entry refusal changes reassembly behaviour under pressure without a stated goal trade-off.
- It is unclear where `idle-waiters` ends up: the plan says "inlined" in two places.
- It never says whether `generation()`'s replacement ("a message holds its Link") keeps a gone `Link` alive in memory.
---
## Plan 3: a `protocol/` translation layer, answers as `Reply` return values, `SmppClient` above a one-socket `Session`
**1. Would it help? Yes.**
- It is the only plan that makes "one answer per request" a type. `replyFor()` returns a `Reply`, and `Session.write()` is the single writer. `onRequest` also returns a `Reply`, and `sendReturn` is gone.
- Bit masks never leave `protocol/`, and a test fails on any bare `§x.y.z` citation. That targets the junior Self-sufficiency cap directly.
- `BoundedStore` is kept simple.
- It removes the lifecycle cap the same way F did.
**2. Predicted scores**
- **Navigation 7.** The areas read in order: protocol → codec → messages → session → client. The only open question is which `sendSms` to call.
- **Locality 7.** Answering, the lifecycle and the drain each live in one function. The exception is the `client.ts` hub.
- **Shape 7.** State sits in three named files and reconnect lives above the socket. `Reply.dlr` is a wart.
- **Self-sufficiency 7.** The glossary is in code, shown on hover, and citations are enforced by a test. `field-types.ts` stays dense.
- **Overall 7.**
**3. Where I would still get stuck.** `client/client.ts`. It holds the current session, the receipt merge, the segment reference counter, the request loop that retries only unwritten requests, event re-emitting, `close`/`unbind` and `fromStart` plus abort. That is F's `SmppClient` with more loaded onto it, and the lessons predict the hardest unit lands here next.
**4. Wrong or vague**
- **The async path is not described.** `replyFor()` is described as pure, but `onSms` is asynchronous. The plan never names the one function that carries a message from `replyFor` through `handlers.ts` to `session.write`. Without it, "exactly one answer" spreads back over three files.
- **A6 (`'ASCII'` renamed to `'GSM7'`) is unjustified.** It breaks every caller for a name the code can gloss once, and goal 8 favours a stable surface.
- **A3 (`Reply.dlr`) adds a second way to send a receipt** beside `sms.sendDlr()`.
- **Refuse-not-evict for reassembly** lets a peer that abandons segment groups block all multipart traffic for `reassemblyTimeout`. That is an operator-facing regression under goal 4, and "goal 2 served better" does not hold for traffic we refuse and the peer then gives up on.
- **A1 renames `session` to `client`** on `client()`'s result. That is defensible: it is honest where plan 1 is not, but it is a real migration cost.
- It is silent on goal 9 (a store interface). `BoundedStore`'s shape should not make that goal harder later.
---
## Verdict
**Ranking:** plan 3, then plan 1, then plan 2.
**Can the best reach 7 overall?** Plausibly yes, as a mean around 6.75 to 7, if it drops A3 and A6. The single change that would most raise its odds: move the retry-only-unwritten request loop out of `client/client.ts` into its own file, like plan 1's `client/next-link.ts`, with its invariant at the top. The same file or function should also carry the async `onSms` reply path, so that neither `SmppClient` nor the answer path becomes the next hardest unit.
**Structure or intrinsic difficulty?** Mostly structure.
- The hardest unit moved every round: the held-message flow, then the lifecycle, then `ExpiringGroups` and the answer invariant. Intrinsic difficulty does not move when you reorganise, so a cap that moves each time is coming from coupling.
- Every draft so far kept at least one object that held both the socket and what outlives the socket, or both the answer and its trigger. The panel scores that worst unit.
- The intrinsic core does set a floor: answering each segment on arrival, the drain's two budgets, retrying only what was never written, and `data_coding`. That floor is about 7 and is not what capped the drafts at 6.
- The coarse four-seat integer scale explains why every drop in difficulty looked like no movement at all.
PLAN1 helps=marginal nav=7 loc=6 shape=6 self=7 overall=7
PLAN2 helps=marginal nav=6 loc=6 shape=6 self=7 overall=6
PLAN3 helps=yes nav=7 loc=7 shape=7 self=7 overall=7
## Architect seat
**Board seat: inherited architect.** I read `link-life.ts` (a four-value phase plus `stopped`, seven predicates, and an initial `'up'`), `expiring-groups.ts` (which says outright that it "enforces neither max nor timeout itself") and `session.ts`. My view matches the panel: 6 overall, Locality 6. The hard spots are ones the code created, not ones SMPP forces.
## Plan 1: folders named after what the developer does, `ClientSession` above a one-socket `Session`
1. **Would it help?** Marginal. It combines F's one-socket split with D's handler that keeps `sendResp()`, and both scored 6 before. Answering is now spread over four files in two folders: `session/owed-answer.ts`, `receiving/receive-message.ts`, `receiving/running-handlers.ts` and `receiving/sms.ts`. The ports (`AnswerPort`, `SendPort`) add names without removing a step.
2. **Predicted scores:**
- Nav 6: folders named after what the developer does are good. But `client()` still resolves `{ session }`, and that value is a `ClientSession` whose `.link` is the real `Session`, so the name lies at the first line of every example. `limits/` is a catch-all name.
- Loc 6: the one-answer rule has one writer, but the path to that writer crosses two folders through ports.
- Shape 6: `sendResp()` and returning from the handler are two ways to answer. "Two `SmsSender`s in client mode" is left unresolved.
- Self 6: the glossary goes in the README, which the lessons say did not lift juniors.
- Overall 6.
3. **Where it still defeats me:** `client/client.ts`. It re-emits the current link's events, answers `boundAs` through the reconnect gap and owns the merger across links, while `client/next-link.ts` retries underneath it. This is F's `SmppClient` again.
4. **Wrong or vague:**
- Keeping the name `session` for a `ClientSession` is harmful. An app developer calls `session.sock` or `session.sendReturn()` and finds them moved to `session.link`.
- Which object owns the `SmsSender` is left open "until chunk 6", and that is the part most likely to rot.
- `sendResp()` plus answer-on-return breaks the one-spelling rule.
- Making `linkEnd` readonly is fine.
## Plan 2: every piece of state has one owner, a private `Link` per socket behind the public `Session`
1. **Would it help?** Marginal, leaning yes. The ownership discipline is the most honest of the three: a phase that only moves forward, one `AbortController` as the only stop signal, and `link/answers.ts` refusing a second answer. But `Session` stays the hub (current link, link wait, send window, reconnect, merger, handover). `Link` reports to it through a five-callback `LinkOwner`, which is the same shape E's panel called hard. The retry still spans links, in `session/outbound.ts` with four lanes.
2. **Predicted scores:**
- Nav 6: a `Session` versus `Link` vocabulary gap. Held messages and reassembly under `link/` surprise a reader who thinks of them as message concerns.
- Loc 6: a transition's meaning is split between `link.ts` and the `LinkOwner` callbacks implemented in `session.ts`.
- Shape 7: one writer per piece of state, and invariants stated at their owner.
- Self 6: `wire/fields.ts` names the bit masks, but the glossary sits in `docs/`, away from where the terms are used.
- Overall 6.
3. **Where it still defeats me:** `session/session.ts`, in `adopt()`/`onGone()` together with `session/outbound.ts`'s loop over `boundLink()`. That is the reconnect lifecycle kept inside the public unit, only renamed.
4. **Wrong or vague:**
- It keeps reconnect inside `Session`, which the lessons show is where the ceiling sits. The plan says the rename to `SmppClient` "bought nothing a reader scores", but F's gain was exactly the removal of the lifecycle from the list of hardest units.
- The four lanes stay four rules.
- Refusing the incoming segment when a store is full is a behaviour change under pressure, and the plan argues it only as a risk.
- The API changes are the most conservative: `sendResp()` becomes the handler's return value, `sendDlr()` before the answer returns `err`, and a double `sendReturn()` returns `err`. All are justified.
## Plan 3: plain-English protocol types, answers as return values, `SmppClient` above a one-socket `Session`
1. **Would it help?** Yes. It is the only plan that attacks all three standing caps at once:
- **Lifecycle:** one socket per `Session`, with reconnect in `SmppClient`.
- **Answering:** an answer is a `Reply` value, `requests-in.ts` is a pure function, and `session.write()` is the single writer. "Exactly one answer" becomes something the type system enforces.
- **Self-sufficiency:** `protocol/vocabulary.ts` gives definitions on hover, `data-coding.ts` becomes a table checked against today's code on all 256 values, and a test fails on any bare spec citation. Of the three, this is the one that actually changes a junior's Self-sufficiency.
2. **Predicted scores:**
- Nav 7: the folder order (`protocol` → `codec` → `messages` → `session` → `client`) tells you where a question is answered.
- Loc 6: `client/client.ts` gathers the current session, state kept across links, the retry loop, event re-emitting, `fromStart` and abort in one file.
- Shape 7: pure `replyFor()`, a store that refuses rather than evicts and never mutates on read, and a lifecycle that only moves forward.
- Self 7: vocabulary beside the code, and the citation rule enforced by a test.
- Overall 7, but only just.
3. **Where it still defeats me:** `client/client.ts` (`SmppClient`). The request loop waits for a bound session across reconnects, retries only what was never written, and re-emits events, all next to the `fromStart` and abort handling. The plan's own risk list predicts this file. `session/handlers.ts` converting a handler's outcome into a `Reply` for a message already answered on arrival comes second.
4. **Wrong or vague:**
- Six breaking changes where two carry the value.
- **A3** (`Reply.dlr`) is a second way to send a receipt beside `sendDlr()`. It is unjustified; drop it.
- **A6** (`'ASCII'` → `'GSM7'`) breaks every caller for a name that can be glossed once inside the library. Drop it.
- **A1** (`{ client }` in place of `{ session }`) is justified, and it is more honest than plan 1's `session` that is really a client.
- Refuse-not-evict means a peer that abandons segment groups blocks all new multipart traffic for `reassemblyTimeout`. The goal 4 regression is argued only in one direction.
- `retained.ts` and `bounded-store.ts` in `messages/` are misfiled, since neither is message logic.
- "Re-emits session events" does not say which events or how.
## Verdict
**Ranking:** Plan 3, then Plan 2, then Plan 1.
**Does plan 3 reach 7?** Plausibly. My odds are about even, because Locality 6 is the cap and the overall may not exceed it by more than one. The single change that most raises the odds: move `SmppClient`'s bound-session wait and unwritten-only retry out of `client/client.ts` into a file of their own (plan 1's `client/next-link.ts` shape) with its `Invariant:` paragraph. `client.ts` is then only composition and state carried across links, and the unit the panel will name next is small and marked. Dropping A3 comes second.
**Structure or intrinsic difficulty?** Structure, including the structure the public contract forced. Every unit the rounds named was one the code created, not the protocol:
- the held-message timing contract;
- `LinkLife`'s predicates and its duplicate stop flag;
- `ExpiringGroups` leaving its bounds to its callers;
- one answer enforced jointly by two files.
The truly hard SMPP parts are few and can be kept in one place each: segments answered on arrival, the drain's two budgets, retrying only what never reached the socket, `data_coding` groups and operator receipt spellings. A 7 allows exactly that. The first two rounds failed because internals were rearranged under a contract that pinned the hard spot in place. The later rounds each removed a created unit and exposed the next one. Nothing yet shows that SMPP itself caps the scores at 6.
PLAN1 helps=marginal nav=6 loc=6 shape=6 self=6 overall=6
PLAN2 helps=marginal nav=6 loc=6 shape=7 self=6 overall=6
PLAN3 helps=yes nav=7 loc=6 shape=7 self=7 overall=7
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-59
View File
@@ -1,59 +0,0 @@
# Lessons from three redesign rounds
A four-seat comprehension panel reads the whole project: a junior, a mid, a maintainability senior and an inherited-system architect. It scores on an absolute 1–10 scale, where 7 = "Predictable: the layout answers where things live; the hard parts are hard because the problem is hard, few, localized and marked". There are four dimensions: Navigation, Locality, Shape and Self-sufficiency. The overall may not exceed the lowest dimension plus one. The target is a mean overall at least one full point above main.
| | Overall per seat | Mean | Locality |
| --- | --- | --- | --- |
| Main today | 6, 6, 7, 6 | 6.25 | 5, 5, 6, 6 |
| A: internals only; a `Link` object per socket | 6, 6, 6, 6 | 6.0 | 5, 5, 6, 6 |
| B: internals only; the lifecycle as one state machine (reducer returns effects, `Session` runs them) | 6, 6, 6, 6 | 6.0 | 6, 6, 5, 6 |
| C: contract change; `onSms` handler option, every message answered `ESME_ROK` on arrival, `sendResp()` removed, `src/` grouped into session/, messages/, wire/, defs/ | 6, 6, 6, 6 | 6.0 | 6, 6, 6, 6 |
| D: contract change; `onSms` handler, message held while its promise runs, `sendResp()` kept, no handler means refuse with the retry status | 5, 6, 7, 6 | 6.0 | 5, 6, 6, 6 |
Their full diffs are `drafts/draft-a.patch` to `drafts/draft-d.patch`; each carries the draft's own DESIGN.md.
What the panels taught:
1. **Restructuring internals under the old contract does not move the scores (A, B).** The held-message timing contract capped every seat: six exits, a `setImmediate` turn, listener counts, `captureRejections` routed through a `WeakMap`.
2. **Changing the receiving contract to an `onSms` handler removed that cap (C, D).** In C no reader named the held-message flow; D's junior still did, because "answered" lived in three places (a closure flag, a store field, and `answeredOnArrival`).
3. **The new ceiling is the session lifecycle.** Seven of eight round-two seats named the same unit they would least want to modify:
- `LinkLife`: a 4-value phase plus a separate `stopped` flag, whose initial `'up'` is an exception to its own rule.
- Seven predicates over it (`isUp`, `isAttached`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`), read by `Session`, `OutgoingRequests` and `IncomingRequests`.
- `Session.linkLost`/`end`/`dropSocket`/`comeBackUp`, whose correctness hangs on call order.
- Listeners of `disconnected`/`close` re-entering `close()` synchronously.
- `ReconnectLoop`'s own stopped flag duplicating `LinkLife`'s.
- client.ts's `bindOn` relying on `close()` reaching `stop()` before its first await, stated in another file.
B tried a single state machine, but under the old contract, where the held-message cap hid any gain.
4. **Also still cited:**
- `IncomingRequests`/`HeldMessages` call back into `Session` (`emit`, `sendReturn`, `close`, `listenerCount`).
- `OutgoingRequests`' several entry points, or lanes, and its retry loop, which depends on link state at each await.
- `ExpiringGroups` leaves enforcement to its three owners.
- The GSM 03.38 codec is still named `ascii` somewhere.
- `DlrMerger.close` really means "spend".
- There is no glossary for the SMPP terms (ESME, SMSC/MC, esm_class, data_coding, UDH, sar_*, TLV).
- SMPP section citations with no summary.
- Defaults are spread over several files.
5. **Goal checks the drafts raised:**
- C answers `ESME_ROK` before the application has taken the message, so a crash loses it. That is a goal 2 risk: "work the peer has no reason to send again is not dropped".
- D's "no handler, so refuse every inbound message with the retry status" is a judgement call. If you keep something like it, record it in docs/decisions.md with the goal it rests on.
## Round three
| | Overall per seat | Mean | Locality |
| --- | --- | --- | --- |
| E: an `onSms` handler whose message is answered when the handler returns, plus the lifecycle as one state machine (`connected, bound, closing, down, ended`) | 5, 7, 6, 6 | 6.0 | 5, 6, 6, 6 |
| F: a Session is one socket's life, bound once and ended once; reconnect is an `SmppClient` composed above it; `onSms` answered on return | 6, 6, 7, 6 | 6.25 | 5, 5, 6, 6 |
Diffs: `drafts/draft-e.patch` and `drafts/draft-f.patch`.
What round three taught:
- **The hardest unit moves every round.**
- Round 1: the held-message flow.
- Round 2: the handler contract removed it, and the lifecycle took its place.
- Round 3: F's one-socket session removed the lifecycle, and readers now name other units:
- `ExpiringGroups` with `Reassembler.trim`, named by 4 of 8 seats: `set()` or `weigh()` may evict the caller's own entry, reads mutate and fire callbacks, and three owners depend on drop order;
- the invariant "every inbound PDU gets exactly one answer", enforced jointly by `IncomingRequests` and `sms.ts`.
- E's state machine was still hard, because each transition's meaning is split across five callbacks in another file.
- **Juniors score Self-sufficiency 5** even with a README glossary. The cost is SMPP knowledge: spec section numbers with no summary, and the `data_coding` bit masks. That caps a junior's overall at 6.
- **A fix sometimes adds a smaller hard spot of its own**, as D's "answered" in three places and E's callbacks show.
- **The scale is coarse**: four integer seats, so one seat moving one point shifts the mean by 0.25.
@@ -1,605 +0,0 @@
# Round 1: drafts A and B
## Draft A, junior seat
1. **Hardest places, ranked**
1. `src/outgoing-requests.ts:75-113` (`OutgoingRequests.request` / `requestDuringDrain` / `carrier`) together with `src/session.ts:336-371` (`linkLost`, `end`, `nextLinkExpected`). Whether a send is refused, waits or retries depends on `life`, `link.canCarry()` and `reconnectLoop`. Those live in `Session` and reach here only through the three `LinkView` closures, so reading one file means holding the other's state in my head. Line 82, `closing() && current().canCarry()`, beat me until I traced `carrier()` → `nextExpected()` → `life === 'open'`. Its comment ("refused as closed further on") points at the answer without giving it. `linkLost` reads `nextLinkExpected()` before `close()`, and only the comment explains why. That comment helped; the rest stayed half-opaque.
2. `src/held-messages.ts:40-170` (`HeldMessage`, `HeldMessages.offer`), with `src/sms.ts:77-162` and `src/session.ts:104`. A message has six exits across three files: a `working` listener counter, `answered()` deferring by `setImmediate`, a `WeakMap` from `Sms` to hold, and `emit()` returning false meaning release. `captureRejectionSymbol` calls `this.link.held.rejected(...)`, which is always the current link, so the message is only found if the link has not been replaced since the emit. The numbered exit list in the doc comment is the only reason I followed this.
3. `src/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`). The `CodingSource` idea is hard: `data_coding` is rewritten from whichever body "owns" it, an empty buffer counts as `message_payload`, and a string gets encoded while a Buffer does not. That is four branches at once. The read side has a hidden coupling too: at `pdu.ts:249` `readParams` passes the already-read `sm_length` to every wire type's `read`. Only the comment at `defs/commands.ts:19-23` hints at it. Partly resolved.
4. `src/defs/encodings.ts:147-190` (`messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`). Bit masks over GSM 03.38 coding groups, and I have no domain background for them. `ASCII` means GSM 03.38 7-bit, a name that lies; only the README encoding table fixed that for me. The `//` comment at 159-160 sits above the JSDoc of the function it describes, so it reads as floating. Stayed opaque at bit level.
5. `src/dlr.ts:157-239` (`messageType`, `receiptStatus`, `dlrFromPdu`). There are four message types, TLV-over-body precedence for both id and state, and an `unmarked` case that needs both an id and a status to count as a receipt. Comments cite spec sections I can't check. It reads correctly, but only after two passes.
6. `src/reassembly.ts:187-207` (`Reassembler.trim`) with `src/expiring-groups.ts:18,70-88`. `weigh()` may evict the very group being added, and `answered = parts.size - 1` subtracts the refused segment. The contract "enforces neither max nor timeout itself; only weigh() evicts" splits enforcement between owner and store. Resolved by the comments, but costly.
7. `src/dlr-merger.ts:150-172` (`open`, `close`). `close()` does not close a group: it moves the base into a second `ExpiringGroups` called `spent`. The misleading name cost me a reread. The class doc resolved it.
8. `src/drain.ts:20-56` (`drain`, `leftOf`, `messagesBudget`). The doc says "one budget", but messages get their own budget with a fallback while requests get what is left. 0 means "forever", so `leftOf` clamps to 1. Small but inverted.
2. **Least want to modify:** `HeldMessages` / `HeldMessage` (`src/held-messages.ts`). Its lifetime is decided by timing (`setImmediate` so that `sendDlr` still goes out past a drain), by listener counts, by identity checks against reused sequence numbers, and by callers in `session.ts` and `sms.ts`. A change to any exit risks a drain that hangs or one that ends early, and nothing local would show it.
3. **Expected hard, found easy:** `PduFramer`, `PendingRequests`, `SendWindow`, `ReconnectLoop`, and the TLV table's type-level keying (`defs/tlvs.ts`). `defs/types.ts` is 684 lines but repetitive and uniform. `client.ts` and `server.ts` are shallow and read top-down.
4. **Prose debt:**
- Needed:
- The README encoding table, to learn that `ASCII` means GSM 7-bit.
- The AGENTS "GSM 7-bit is sent unpacked" section, to see why `segmentUnits` holds 153 against 134.
- The README "Server in depth" and "Shutdown" sections, to understand `answeredOnArrival` and what the drain waits for.
Finding them meant scanning a 773-line README; AGENTS has no anchors from code to sections.
- The decisions index in AGENTS gives titles only. The reasoning is in `docs/decisions.md`, which I was not allowed to read, so rules like "a drain ignores `shutdownTimeout: 0`" had to be recovered from code comments.
- Told me nothing the code did not:
- The 0.4.0 defect table, which says nothing about the current code.
- Most of the AGENTS architecture list, which restates filenames.
- Most decision-index lines, which repeat what an adjacent code comment already says.
- One-liners such as "Sends a request and resolves with the peer's response" on `send()`.
I opened no tests.
5. **Scores.** The problem is intrinsically hard (a protocol I don't know, plus async link lifecycles); that gets no bonus below.
- **Navigation 7 (Predictable):** file names match behaviours one-to-one, but "what happens to a send during shutdown" lives across `session.ts`, `outgoing-requests.ts`, `link.ts` and `drain.ts`, with no single entry point.
- **Locality 5 (Honest middle):** `Session` injects its state as closures (`LinkView`, `Link.on`) and passes itself back into `HeldMessages` and `IncomingRequests`, and correctness hangs on ordering that is only named in comments (`linkLost` before `close`, `setImmediate` in `answered`).
- **Shape 6 (between 5 and 7):** fan-out stays bounded per level, but several names lie: `ASCII` for GSM, `DlrMerger.close` for "mark spent", `string` for a length-prefixed Octet String next to `cstring`, and `answered()` meaning "release a turn later".
- **Self-sufficiency 6 (between 5 and 7):** dense, spec-citing comments carry most units, but the domain vocabulary (GSM alphabet naming, segment budgets, what `answeredOnArrival` means) needs the README open beside the code.
- **Overall 6:** capped at locality plus one by the cross-file lifecycle state.
SCORES nav=7 loc=5 shape=6 self=6 overall=6
## Draft A, mid seat
1. **Hardest places, hardest first**
1. `src/held-messages.ts:252-435`, `HeldMessage` / `HeldMessages` (and `session.ts:95-107`, the `captureRejectionSymbol` override). There are six exits, each in a different method. I had to trace this chain across files: a listener rejects, Node's `captureRejections` calls the session, the session calls `this.link.held.rejected(rest[0])`, a WeakMap is searched by object identity, `listenerGaveUp()` counts down from a `listenerCount('sms')` taken when the message was offered, `answered()` waits a turn in `setImmediate`, `release()` compares the array by identity, `settle()` runs, and finally `IdleWaiters` wakes the drain in `drain.ts`. The comment listing the six exits made it readable. What stayed unclear: why the rejection goes to the *current* link's store, which may not be the link the message arrived on after a reconnect. I also had to work out that `send()` choosing `sendPastDrain` exists only because `OutgoingRequests.request` refuses sends while closing.
2. `src/outgoing-requests.ts:510-613`, `request` / `requestDuringDrain` / `bindOnCurrentLink` / `requestOnCurrentLink` / `carrier`. That is four ways onto the wire, and each skips a different check. Line 517 (`closing() && current().canCarry()`) refuses only when a link is up. The comment "refused as closed further on" meant tracing `carrier()` to see that `nextExpected()` is false while closing. `responseTimeout` is reused as the deadline for waiting on a link, `deadline === 0` means forever, and the retry loop depends on `retryOnNextLink` from `Link.send`. The `LinkView` closures read `Session` private state from a distance. The comments resolved most of it after two reads.
3. `src/session.ts:208-366`, `unbind` / `drain` / `comeBackUp` / `linkLost` / `end`. The ordering does the work. `unbind` drains, then goes around the closing refusal via `requestOnCurrentLink`. `closedOnUnbind` decides which error wins. `comeBackUp` sets `this.link` before the bind succeeds, and `linkLost` must read `nextLinkExpected()` before `close()` because a listener may re-enter. The inline comments ("Read before close()…", "close() can land while…") resolved it, but I had to hold five states at once.
4. `src/reassembly.ts:187-207`, `Reassembler.trim`, with `src/expiring-groups.ts:245-315`, `ExpiringGroups.weigh`. `ExpiringGroups` applies its three limits differently: the owner checks `full`, the owner calls `takeExpired`, and only `weigh` evicts. Map insertion order stands in for age, and `set()` re-inserts, so a replaced entry becomes the newest. `trim` can evict its own group, and it then counts `size - 1` as lost because the refused segment "stays with the peer". The comments state each rule, but checking the arithmetic took three rereads.
5. `src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`, plus `pdu.ts:249`. `CodingSource` decides whether `short_message` or `message_payload` is allowed to set `data_coding`, and an empty buffer flips the answer. `readParams` passes `sm_length` as the length argument to *every* field read, and only `buffer.read` uses it. That is a hidden coupling that neither line mentions. With no SMPP background this stayed half-opaque.
6. `src/defs/encodings.ts:1,105-190`, `EncodingName` and `messageClassEncoding` / `encodingByDataCoding`. The name `'ASCII'` means GSM 03.38, and nothing says so until `dataCodingByEncoding`'s comment. It also misleads in `message.ts:343` and `message.ts:402` (`resolved === 'ASCII'` means septet packing). The bit masks (`0x80`, `0xF0`, bits 3-2) were an algorithm I had no context for. Two comments sit stacked in reverse order at lines 159-161. The charter's "GSM 7-bit is sent unpacked" section explained the 153.
7. `src/dlr.ts:357-439`, `messageType` / `receiptStatus` / `dlrFromPdu`. There are four message types, and `'unmarked'` becomes a receipt only if both an id and a state can be scraped. Otherwise `IncomingRequests.onDelivery` (`incoming-requests.ts:152`) quietly reroutes it to `onMessage`. The rule is local and commented, but it only makes sense with the spec's `esm_class` bits in mind.
8. `src/client.ts:258-330`, `keepTrying` / `initialAttempts`. There is a second `ReconnectLoop` outside the session, a fresh `Session` per attempt, and a `lastErr` captured in closures. The code itself is clear; the cost was noticing that there are two loops.
2. **Unit I would least want to modify:** `HeldMessages` / `HeldMessage` (`src/held-messages.ts`). Its correctness depends on timing (`setImmediate`), a listener count taken at `emit` time, identity lookups (the WeakMap, and array identity in the store), and callers in `session.ts`, `incoming-requests.ts`, `sms.ts` and `drain.ts` that each use a different exit. A change there fails as a hung or cut-short shutdown, which is hard to see in a test.
3. **Expected hard, found easy:** the wire codec. `defs/types.ts` is long but uniform. `PduFramer`, `parseTlvs` / `writeTlvs`, `readOptionalParams` (its NULL-pad rule is commented), `ReconnectLoop`, `bind-direction.ts`, `sms-id.ts` and the `Result<T>` convention were all quick. `udh.ts`'s `concatInfo` explains its walk over the header elements well enough for a newcomer to the domain.
4. **Prose debt**
- **Needed:**
- The AGENTS.md architecture map was cheap and correct; it is how I found every file. The file list matches `src/`.
- The "GSM 7-bit is sent unpacked" section was necessary for `segmentUnits`.
- The README's Receive-SMS text was necessary to see why `sendResp()` on a multipart message writes nothing.
- Missing everywhere: a one-line glossary of ESME/SMSC, `esm_class`, `data_coding`, UDH and `sar_*`. The only one is the `LinkEnd` comment for ESME/SMSC. Comments cite spec sections ("SMPP 3.4 5.2.12") that I cannot open, so for someone new to the domain they are pointers, not definitions.
- The decisions index names rules ("the drain's wait on the application ignores `shutdownTimeout: 0`") that I then found stated in the code (`drain.ts` `messagesBudget`). The index cost scrolling and gave nothing the code did not.
- I opened no tests.
- **Told me nothing new:**
- The AGENTS "Defects found in 0.4.0" table: history, not needed to read this code.
- Most of the Conventions paragraph on test fixtures, for reading `src/`.
- Doc comments that restate the code: `Link.canCarry` ("Whether a request can go out on it right now"), `HeldMessages.isGone`, `Session.bindAllows` ("Consulted by the library's senders"), `IdleWaiters.settle`, `PduRefusedError`'s class comment.
5. **Scores**
- **Navigation 7:** at the "predictable" anchor. The one-line-per-file map and concept-named files (`link.ts`, `drain.ts`, `reassembly.ts`) got me from symptom to file first try. It stops short of 9 because shutdown behaviour lives in five files (`session.ts`, `drain.ts`, `held-messages.ts`, `outgoing-requests.ts`, `idle-waiters.ts`).
- **Locality 5:** at the "honest middle" anchor. Most modules stand alone, but the held-message/drain path runs on hidden timing (`setImmediate`), a listener count taken early, rejection routing to whatever link is current, and `LinkView` closures reading `Session` private state. The order-dependent sequences in `Session.linkLost` and `unbind` add to it.
- **Shape 6:** between the middle and predictable anchors. Fan-out is bounded (`Session` → `Link` → `PendingRequests` / `HeldMessages` / `Reassembler`), but some names lie or clash:
- `'ASCII'` means GSM 03.38.
- In `sms.ts`, `answered` is both a mutable `{ smsId }` holder (line 81) and a handler function (line 69).
- `HeldMessage.held.held` chains through two different things both called `held`.
- `lostLink()` is a predicate named like an event.
- There are four request entry points on `OutgoingRequests`.
- **Self-sufficiency 6:** between the middle and predictable anchors. Comments are dense and carry the why at the call site (the six-exit list, "Read before close()"). But domain terms are never glossed and the spec section numbers point outside the repo, so the `data_coding` and `esm_class` bit logic in `encodings.ts` and `dlr.ts` cannot stand alone for a reader new to SMPP.
- **Overall 6:** capped at locality + 1. The hard parts are few and mostly marked, but the one I would fear most (held messages and the drain) spreads across files and relies on timing.
- **Intrinsic difficulty** (no bonus): moderate-high. The protocol has two segmentation spellings, receipts that share a command with messages, a direction-dependent `data_sm`, and graceful drain combined with reconnect.
SCORES nav=7 loc=5 shape=6 self=6 overall=6
## Draft A, senior seat
1. **Hardest places, ranked**
1. **`src/held-messages.ts:40` `HeldMessage`, and `HeldMessages.offer` at `:148`.** Following this one flow meant holding five files at once:
- `sms.ts:127` `sendResp` and `:210` `sendDlr`.
- The `setImmediate` in `answered()` at `:58`.
- `HeldMessage.send` at `:77`, which picks between `sendPastDrain` and `session.send` by asking `isHeld()`.
- `OutgoingRequests.requestDuringDrain`.
- `Session`'s `captureRejectionSymbol` at `session.ts:104`, which gets back to the hold through a `WeakMap` keyed on the `Sms`.
A `sendDlr()` gets past a drain only while the release has not yet happened, and that is one event-loop turn. `working` is `listenerCount('sms')` taken at offer time, and the guarded `emit` returning false feeds exit 3. The six-exits comment and the README's Shutdown section settled it, but only after I had read both.
2. **`src/outgoing-requests.ts:75` `request`, with `:90` `requestDuringDrain`, `:115` `bindOnCurrentLink`, `:133` `requestOnCurrentLink` and `:160` `carrier`.** There are four ways onto a link, and each skips a different mix of four things: the drain refusal, the send window, the wait for a link and the retry.
- Line 94, `closing() && current().canCarry()`, only makes sense with the comment "refused as closed further on".
- `misuse()` is checked twice.
- Whether the bind and the unbind count toward the drain's `window.idle()` has to be worked out from the fact that they skip `attemptOn`.
I followed it in the end, but did not come away sure of the edge cases.
3. **`src/session.ts:234` `answer`, with `incoming-requests.ts:85` and `sms.ts:127`.** `sendReturn` always writes to `this.link`, the current link. The rule that "an answer belongs to the link the message arrived on" is held by callers checking `link.isClosed()` or `lostLink()` before they call, in two separate places. `sendReturn` never enforces it. I had to hunt for this, and only the decision titles in AGENTS.md told me the rule exists.
4. **`src/pdu.ts:84` `resolveShortMessage` / `:113` `resolveBody`.** `CodingSource` decides whether `short_message` or `message_payload` sets `data_coding`. I had to hold these cases at once:
- Buffer or string or absent.
- Empty or non-empty.
- `data_coding` given or not.
- An empty `short_message` that still makes `message_payload` the source.
The type comment at `:74` helps. It still took two reads.
5. **`src/reassembly.ts:111` `collect` / `:188` `trim`, on top of `expiring-groups.ts:18`.** `ExpiringGroups` enforces its limits unevenly:
- `set()` never evicts and `weigh()` does.
- `full` is only advisory.
- `onSweep` must itself call `takeExpired()`.
`trim` counts `parts.size - 1` because the segment was added before weighing and may be the one evicted. The comments state each quirk, but I needed all of them at the same time.
6. **`src/session.ts:208` `unbind` and `:336` `linkLost`.** In `unbind`, the three booleans `wasOpen`, `closedOnUnbind` and `drained` decide which error wins. In `linkLost`, "read `nextLinkExpected` before `close()`" depends on `link.close()` calling `reassembler.clear()`, which emits `sessionError` synchronously to a listener that might call `close()`. That is state changed out of sight. The comment names the risk but not the path it takes.
7. **`src/dlr-merger.ts:150` `open` / `:165` `close`.** Here `close` means "mark as spent", not "tear down", and `spent` is a second `ExpiringGroups<true>` with its own cap and eviction. The class comment explains the purpose, but the method name misleads.
8. **`src/client.ts:286` `keepTrying` / `:263` `initialAttempts`.** There are two different `ReconnectLoop` owners: the session's loop, and a separate one that runs only for the first connect. There is also a `lastErr` closure and a comment about `unref: false`. It was readable once I saw that `fromStart` builds a fresh `Session` for every attempt.
2. **The unit I would least want to modify:** `OutgoingRequests` together with its callers `HeldMessage.send` and `Session.unbind`. Whether a send is refused, queued or bypassed depends on which of the four entry points was chosen, and those choices are made in three other files. A change to one bypass has no local test of whether the drain still counts it.
3. **Expected hard, found easy:**
- The codec: `defs/types.ts` and the TLV read/write. It is mechanical and every read is range-checked.
- UDH walking in `udh.ts`.
- GSM encoding and `splitMessage`.
- Parsing receipt dates.
- `ReconnectLoop`'s backoff reset.
These are local and commented at the right spots, with SMPP section references.
4. **Prose debt.**
- **Needed:** the AGENTS.md architecture map (accurate, and my main way to navigate). The README "Session / Shutdown" and "Sends and the link" bullets, for the drain and held-message rules. The AGENTS decision-index titles, for why answers are tied to a link and why a close after our own `unbind` is clean. Cost was moderate: all of it in two files I had already read, but the "one turn later" rule for `sendDlr` is stated in full only in README step 1.
A comment that is wrong: `SessionOptions.shutdownTimeout` says it bounds only "the requests already on the wire", but `drain.ts:25` also uses it for held messages.
Defaults are scattered with no pointer between them:
- A separate `defaults` object in each of `client.ts`, `server.ts` and `session-options.ts`.
- `backoffDefaults` in `reconnect-loop.ts`.
- `defaultMaxOctets` in `reassembly.ts`, which duplicates `maxHeldOctets`.
Finding where the default for a given option lives took a search.
- **Told me nothing about the current code:**
- The AGENTS 0.4.0 defects table: history, and it never helped me read `src/`.
- Most of AGENTS "Conventions", which is about test fixtures and teardown.
- One-liners that restate the code, such as `/** Sends a request and resolves with the peer's response. */` and `/** Answers a request the peer sent us. */`, plus the `ConcatInfo`/`udhLength` comments.
I did not open any test.
5. **Scores.** The problem is intrinsically hard: an SMPP session layer with reconnect, drain, windowing, reassembly and receipt merging. It gets no bonus here.
- **Navigation 7** (anchor 7): the AGENTS file map answers "where does this live" accurately for every file. It is held below 8 because a symptom like "`sendDlr` refused during shutdown" lands across four files, and "which default" across three objects all called `defaults`.
- **Locality 6** (between 5 and 7): the codec, defs, dlr and message modules are fully local. The session layer is not: `Session` passes itself into `IncomingRequests`, `HeldMessages` and `createSms`, the link-answer rule is enforced by callers, and the drain bypass depends on a `setImmediate` in another file.
- **Shape 6** (between 5 and 7): fan-out is bounded and most names are true. Some are not:
- `ASCII` means GSM 03.38.
- `HeldMessages.full()` sweeps, logs and flips state.
- `DlrMerger.close` means "mark as spent".
- `Session.link` is documented as "the latest socket".
Four near-identical send entry points on `OutgoingRequests` also cost this score.
- **Self-sufficiency 7** (anchor 7): the why-comments sit where they are needed (the six exits, the read-before-close note, the SMPP section numbers). Only the drain and held-message semantics needed the README open beside the code.
- **Overall 6:** capped at 7 by locality, and held at 6 because the part that is hardest to change safely, the session layer, is also where the cross-file coupling sits.
SCORES nav=7 loc=6 shape=6 self=7 overall=6
## Draft A, architect seat
**Comprehension panel report: Architect, inherited.** Target: `/tmp/claude-1000/-home-lilleman-code-smpp-js/fe9b6791-4543-5342-9fc7-efa1e22d8fc7/scratchpad/draft-a`
I read README.md, AGENTS.md in draft-a, and every non-test file under src/. I read `defs/` down to its exports and skimmed the rest of it for shape. I opened no test.
## 1. Map from README and the file tree only (verbatim)
```
Top-level areas I expect (7):
A. Entry/wiring — index.ts (public surface), client.ts (connect+bind+reconnect policy), server.ts (listener, auth, sessions set).
B. Session life — session.ts (the EventEmitter, lifecycle, close/unbind), session-options.ts (options + defaults + validation),
bind-direction.ts (bind types, which end, what a bind allows), reconnect-loop.ts (backoff), link.ts (?? one socket? timers?),
drain.ts (graceful-shutdown wait).
C. Requests out — outgoing-requests.ts (send path), pending-requests.ts (seqNr correlation + timeout),
send-window.ts (maxOutstanding), unanswered-error.ts (the "may have been taken" error), idle-waiters.ts (?? something waits for zero).
D. Requests in / messages — incoming-requests.ts (dispatch of what the peer sends), sms.ts (the 'sms' handle, sendResp/sendDlr),
held-messages.ts (the 1000-unanswered bound from README "Unanswered messages"), reassembly.ts + expiring-groups.ts
(multipart store; expiring-groups probably shared), concat.ts + udh.ts (segment detection UDH vs sar_*),
message-body.ts (short_message vs message_payload), message.ts (encode/split), send-sms.ts (sendSms composition),
retained-pdu.ts (?? memory accounting per maxOctets).
E. Receipts — dlr.ts (parse), dlr-merger.ts (messageDlr), sms-id.ts (smsIdFormat notations, <base>-<n>).
F. Codec — pdu.ts, pdu-framer.ts, pdu-refusal.ts (PduRefusedError), defs/* (spec tables, wire types, encodings).
G. Plumbing — result.ts, log.ts, error-from.ts (?? error from unknown), uuid.ts.
Unclear by name: link.ts, idle-waiters.ts, retained-pdu.ts, error-from.ts; overlap suspected between drain.ts / idle-waiters.ts / held-messages.ts.
Expected but not visible: no keepalive/timer file (README's enquire_link + idleTimeout) — guess it's in link.ts or session.ts;
no socket/transport file; no store (goal 9) — README says it has not shipped, so absence is honest; interop-tests/ and benchmarks/ are outside src/test.
```
**Where the map was wrong, and what each correction cost:**
- **link.ts (medium).** I expected a socket plus its timers. `Link` also owns the `PendingRequests`, the `Reassembler` and the `HeldMessages`. So held messages and reassembly belong to one socket, not to the session. That changes how you reason about the drain after a reconnect, and I had to rebuild part of the model.
- **held-messages.ts (medium).** I expected a counter. It holds six exit paths, a WeakMap for listener rejections and a back-reference to `Session`. It also sends through a bypass path (`sendPastDrain`).
- **sms.ts (low to medium).** I expected only the inbound handle. It also builds outbound delivery receipts (`receiptText`, `receiptTlvs`, `collectReceipt`), while `dlr.ts` parses them. Writing and reading receipts are split across two files.
- **Smaller surprises (low).** `reassembly.ts` exports `decodeSegments`, which `sms.ts` uses to build `sms.message`. `message.ts` also holds `smppTime` and `smppDate`.
- **drain, idle-waiters, retained-pdu, error-from (cheap).** Each turned out as guessed. `drain.ts` is budget arithmetic only.
- **Keepalive (right).** The timers are in `link.ts` (`resetTimers`).
## 2. Fan-out level by level
- **L0, repo:** src/, test/, README, AGENTS. Trivial.
- **L1, src/: 35 files plus defs/, all flat. This is the worst level.** My map needed 7 areas, but the layout shows none of them, and the AGENTS.md architecture list is not grouped by area either. I had to hold about 36 names to sort them.
- **L2, defs/:** 7 files, all spec tables. Bounded.
- **L3, units:**
- `session.ts`: about 20 members, but grouped, with a stated invariant ("every event about the life is emitted from one of these four").
- `link.ts`: about 12 members.
- `outgoing-requests.ts`: 4 public ways in (`request`, `requestDuringDrain`, `requestOnCurrentLink`, `bindOnCurrentLink`), the one level where the fan-out is too wide for the concept.
- `defs/types.ts`: 684 lines, but a table of wire types, so it is wide without being hard.
## 3. Names
**Misleading:**
- **`idle`** means two things. `HeldMessages.idle()`, `SendWindow.idle()`, `OutgoingRequests.idle()` and `IdleWaiters` mean "wait until the count reaches zero". `idleTimeout` and "closing an idle peer" in link.ts mean the peer has gone silent.
- **`ExpiringGroups`** enforces neither its `max` nor its timeout (its own comment says owners must). `DlrMerger.spent` is an `ExpiringGroups<true>`, a set dressed as groups.
- **`EncodingName 'ASCII'`** means GSM 03.38. It is public, legacy and documented, but it is still a false name.
- **`lostLink()`** on `SmsHandlers` is a predicate named like an event.
- **`message.ts`** also holds `smppTime` and `smppDate`.
**One concept with two or more names:**
- **Answering a request has four spellings:** `sendReturn`, `pduReturn`, `Session.answer()` and `sendResp`.
- **Letting a send past the drain has two:** `sendPastDrain` and `requestDuringDrain`.
- **The socket has three:** link, `sock` and socket.
- **A delivery report has three:** receipt, dlr and report.
- **A segment has two:** part and segment.
**One name over two concepts:**
- **"held"** covers messages the application has not answered (`Link.held`), requests waiting for a link ("holding a request until a link is back", "sending what was held for a link"), and `HeldMessages.held`, the inner ExpiringGroups.
- **`drain`** is both `Session.drain()` (private) and `drain()` in drain.ts.
- **`defaults`** is three different objects: session-options.ts, client.ts and server.ts, with `systemId` in two of them.
- **`Waiter`/`waiting`** is defined separately in outgoing-requests.ts and send-window.ts with different meanings.
- **"refuse/refusal"** covers codec refusal (`PduRefusedError`), segment refusal (`Refusal 'full'|'unplaceable'`) and option refusal in send-sms.
## 4. What I would restructure, ranked
1. **Group src/ into about 5 directories:** codec/, link+session/, inbound messages/, outbound send/, receipts/, plus defs/. This is the only thing pushing L1 past its bound. AGENTS.md records "src/ stays flat" as a decision, and I would contest it: at 36 files, the map lives in AGENTS.md, not in the layout.
2. **Settle on one verb for answering a request.**
3. **Move receipt composition out of sms.ts** next to the parsing in dlr.ts. Merge `collectReceipt` (`sms.ts:188`) with `collectSent` (`send-sms.ts:274`); they are near-duplicates.
4. **Make `ExpiringGroups` enforce its own `max` and weight, or rename it to say the caps are advisory.** Today three owners each implement eviction differently: `Reassembler.open` plus `trim`, `DlrMerger.dropOldest` plus `spent`, and `HeldMessages.full()` with its own weight comparison.
5. **Rename the "wait until zero" methods** (`idle()` → `drained()`), and give "held" one meaning.
6. **Move `smppTime` and `smppDate` into their own module, and keep one `defaults`.**
**What the structure gets right:**
- `session.ts` is a readable orchestrator with a single exit for a link (`linkLost`) and a single end (`end`).
- `Link.close()` runs once and takes down everything tied to that socket.
- The Result discipline is uniform.
- The codec is pure and synchronous.
- Small modules with honest names: `pdu-framer`, `send-window`, `pending-requests`, `reconnect-loop`, `unanswered-error`.
- Log messages are static and prefixed with the unit (`'heldMessages - …'`, `'drain - …'`), so a log line leads straight to its unit. This is the strongest navigation aid in the code.
- Comments give the WHY, often with a spec section.
## 5. The 3am question
Symptom: during a graceful shutdown the session hangs until the shutdown timeout, even though the application called `sms.sendResp()` on every message.
**Cold time to the right unit: about 5 minutes.**
1. `Session.close` leads to `Session.drain` (`session.ts:251`).
2. That leads to `drain()` (`drain.ts:32`). Its err text and warn logs already say which half stalled: "messages unanswered" or "requests unfinished".
3. **Messages half:** `HeldMessages.idle` and `HeldMessages.release` (`held-messages.ts:185-222`), reached from `HeldMessage.answered()` (`held-messages.ts:57`).
- The gate is `sms.ts:159`, `if (!failure) handlers.answered()`. If any `sendReturn` for the message fails, the message is never released. Two ways that happens: an `smsId` the latin1 codec refuses, or a failed write. It then stays held until the drain budget runs out (and the 5-minute sweep drops it after that). The application saw `err` from `sendResp()` and ignored it.
- Release also works by object identity (`held.get(key) !== pduObjs`), so check that too.
4. **Requests half:** `sendDlr` goes past the drain through `requestDuringDrain` (`outgoing-requests.ts:90`). It holds a send-window slot until the peer answers the `deliver_sm`, so a peer that is itself shutting down leaves it until the deadline. That is the other likely cause, and nothing in the symptom rules it out.
**Where it rots first:** the triangle of `HeldMessage`, `HeldMessages`, `Sms` and `Session`.
- `Link` is handed a `session` through its options.
- `HeldMessages` emits `'sms'` on `Session`.
- A listener rejection comes back through `Session[captureRejectionSymbol]` into `this.link.held.rejected(rest[0])` (`session.ts:104`). That is the current link, not necessarily the one the message arrived on.
- Release depends on ordering: `setImmediate` in `answered()` exists so that a `sendDlr()` called straight after `sendResp()` still gets past the drain.
- The next fix here will add a seventh exit.
**Where the next two features would land:**
- **Goal 9, the store.** It lands on `ExpiringGroups`, the store its three owners share. That class is synchronous and in-memory, and each owner enforces part of its contract, so moving to an async store interface touches `DlrMerger`, `Reassembler` and `HeldMessages` at once. Expensive.
- **Goal 7, a per-PDU rate limit or a custom alphabet.**
- A rate limit lands cleanly at `OutgoingRequests.attemptOn` (`outgoing-requests.ts:124`).
- A custom alphabet runs into the closed `EncodingName` union. It spreads into `message.ts:14` (`segmentUnits`), `send-sms.ts:92` (`dataCodingFor`, with hard-coded 0x10/0x18), `defs/encodings.ts` and `bitCount`: 4 to 5 places.
## 6. Hardest places, ranked
1. `src/held-messages.ts:40-223`, `HeldMessage` and `HeldMessages`: six exits, release by identity, WeakMap for rejections, `setImmediate` release, back-reference to the session, the drain bypass.
2. `src/session.ts:95-107`, `[captureRejectionSymbol]`: a rejected `'sms'` listener reaches the held messages through an `unknown` argument on the current link. Action at a distance.
3. `src/outgoing-requests.ts:75-137`: `request`, `requestDuringDrain`, `bindOnCurrentLink` and `requestOnCurrentLink` are four ways in with different bypass rules. The condition `closing() && current().canCarry()` (line 82) reads inverted until you notice the fall-through.
4. `src/expiring-groups.ts:19-147`, `ExpiringGroups`: the contract is half-enforced, and each owner fills in the rest differently.
5. `src/dlr-merger.ts:68-184`, `DlrMerger`: `groups` plus `spent`, and `close()` always marks an id spent.
6. `src/drain.ts:20-57` with `src/session.ts:251`: budget arithmetic where 0 means forever and the messages half falls back to `responseTimeout`. `session-options.ts:63` documents `shutdownTimeout` as covering only the requests on the wire, a partial truth next to the code that owns the behaviour.
7. `src/client.ts:228-330`, `bindOn`, `initialAttempts` and `keepTrying`: a second `ReconnectLoop` outside `Session`, with a fresh session per attempt.
8. `src/pdu.ts:84-136`, `resolveShortMessage` and `resolveBody`: which body field gets to set `data_coding`.
**The unit I would least want to modify:** `HeldMessages` and `HeldMessage` in `src/held-messages.ts`.
**Intrinsic difficulty (no bonus):** high. An SMPP session layer with reconnect, a drain split into two budgets, sequence-number correlation that belongs to one link, reassembly under caps, and asynchronous lifecycle races. Most of the hard spots are hard because of this.
## 7. Scores
- **Navigation: 7.** At the "Predictable" anchor: honest file names plus unit-prefixed static log strings took the 3am symptom from `session.ts` to `drain.ts` to `held-messages.ts`/`sms.ts:159` with no detour. It stays below 9 because the flat 36-file src/ makes you consult AGENTS.md's list to find areas.
- **Locality: 6.** Between the anchors: `Link` and `Session` hold clean boundaries, but the held-message flow reaches back into `Session` through `Link` options, relies on `setImmediate` ordering, and gets rejections through `captureRejections` on the current link. And three owners each re-enforce `ExpiringGroups`' caps.
- **Shape: 6.** Between the anchors: the flat L1 of 36 files breaks the bound and has no grouping. "held", "idle", `defaults` and "drain" each name two things, and a response has four verbs. The files are small and single-purpose.
- **Self-sufficiency: 7.** At the "Predictable" anchor: invariants are stated at the code (the six-exits list, the `linkLost` comment, spec citations), and I needed no second document to follow a unit. It stays below 9 because of the partial `shutdownTimeout` doc at `session-options.ts:63` and the drain semantics, which only README fully states.
- **Overall: 6.** Held to the Locality and Shape 6s. It reads close to "Predictable": a cold senior would be productive within a week and would know to fear `held-messages.ts`.
SCORES nav=7 loc=6 shape=6 self=7 overall=6
## Draft B, junior seat
1. **Hardest places, ranked hardest first**
1. **`src/outgoing-requests.ts:70-168`, `OutgoingRequests.request` → `requestPastDrain` → `carry` → `attempt`.** A request has four ways in. One is a recursive retry (`carry` calls itself at :130) that fires only when `retryOnNextLink && link.awaitsNextLink()`. Each hop asks `LinkLife` a different question: `isStopping`, `canCarry`, `refusal`, `budget`, `awaitsNextLink`. I had to keep LinkLife's phase table open to follow it. The guard at :77, `isStopping() && canCarry()`, has the comment "With no link, the request is refused as closed further on". That describes the branch that is *not* taken here, and I read it three times. `requestPastDrain` is named for the caller that needs it (a receipt during a drain), not for what it does. The `UnansweredError` wrap at :167 was clear. The rest stayed half-opaque.
2. **`src/link-life.ts:74-171`, `LinkLife.transition` / `lose` / `end`.** There are three pieces of state: `linkPhase`, `stopping` and `drops`. `stopping` is set in two places (:89, :167). `drops` increments both in `lose` and in `end`, and `bound` while stopping falls through to `lose()`. The effects returned are carried out elsewhere, in `session.ts:269` `run()`, so behaviour is split across two files in an order I had to trust. The type comments at :13-33 made it tractable. What really cost me was the initial phase at :60: `linkPhase = 'up'` on a socket that has not bound yet. That contradicts the `up` doc ("a bound socket carries requests") and the charter's "a bind is what makes it one". I never resolved why the first link starts `up`.
3. **`src/held-messages.ts:40-181`, `HeldMessages`.** The numbered "six ways a hold ends" comment helps, but the ways live in three files. Way 2 enters from `session.ts:97`, where `captureRejectionSymbol` passes `rest[0]` as `unknown`. It is then looked up in a `WeakMap`, and `working` is seeded from `session.listenerCount('sms')` at :161. Way 1 arrives through a callback built in `sms.ts`. Way 3 depends on `emit` returning false, both for no listener and for a throw (the override in `session.ts:78`). I pieced this together; it did not stay opaque, but it was the most action at a distance in the codebase.
4. **`src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`.** `CodingSource` decides which of two fields may set `data_coding`. An empty buffer counts as `message_payload`, and `sm_length` is filled only sometimes. With no SMPP background I could not tell why an empty `short_message` hands authority to the TLV until I read `message-body.ts` and the README's "Where the body is". After that it made sense, but it took two files and a doc.
5. **`src/client.ts:228-350`, `bindOn` / `initialAttempts` / `keepTrying` / `client`.** The `fromStart` path builds a second `ReconnectLoop` outside any session. Each attempt gets a fresh `Session`, and a `lastErr` closure is shared between two lambdas. The comment at :249, "close() must reach the loop's stop() before its first await", states an ordering invariant that lives in `session.ts` `drain()`/`apply('stopping')`. It is correct, but invisible from here.
6. **`src/defs/encodings.ts:151-198`, `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.** This is bit-masking over `data_coding` groups I had no context for. The `//` comment sits above the `/** */` block at :159-161, so it reads as belonging to the wrong function. The "ASCII" name meaning GSM 03.38 is a lie I only caught because the README's encoding table says so. It stayed partly opaque: I trust it, but I could not verify it.
7. **`src/reassembly.ts:188-207`, `Reassembler.trim`.** `ExpiringGroups.weigh()` returns evicted groups, possibly including the current one. `answered = parts.size - 1` for the current group depends on the refused segment already being in `parts`. The comments at :200 and :125 resolved it after a reread.
8. **`src/drain.ts:70-112`, `drain` / `answeringBudget`.** Two budgets with different zero-semantics, a fallback to `defaults.responseTimeout` imported from `session-options`, and a `setImmediate` turn. The comments explain each step. It was harder than it looked, but it resolved.
2. **The unit I would least want to modify:** `OutgoingRequests.carry` together with `requestPastDrain` (`src/outgoing-requests.ts:85-133`). Its correctness depends on LinkLife's phase at the exact moment of each await: whether a failed write has already produced `lost` → `dropLink`, so that `awaitsNextLink()` is true. It also depends on the send-window slot being released in `finally` before the recursion. Nothing in the unit states those timing assumptions. A change would be guessed and then tested.
3. **Expected to be hard, found easy.**
- The wire codec: `defs/types.ts` is long but completely regular, and every read/write is range-checked.
- `PduFramer`, `concatInfo`, `sms-id.ts`, `dlr.ts` receipt parsing (comments name the operators and spec sections).
- The `Result` pattern.
- `server.ts` `handleRequest`.
- `DlrMerger`, whose severity table comment says exactly why it exists.
- The session constructor, which reads as a wiring diagram.
4. **Prose debt.**
- **Needed:**
- The SMPP vocabulary: ESME vs SMSC, `deliver_sm` vs `submit_sm` direction, `esm_class`, `data_coding`, TON/NPI, UDH vs `sar_*`. Only the README supplies it, scattered across "Receiving in depth", "Bind direction" and "Delivery receipts". There is no glossary, and finding each term cost several searches.
- The charter's "GSM 7-bit is sent unpacked" section, to understand `segmentUnits` in `message.ts:14`. Cheap, because the architecture map pointed there.
- The AGENTS decision index. Its lines, for example "One owner decides whether a link can carry a request", told me a rule exists but not its reasoning. I was not allowed to open `docs/decisions.md`, and the initial-`up` puzzle is exactly where I needed it.
- **Told me nothing:**
- The 0.4.0 defect table. It is history, and useless for reading the current code.
- The long test-fixture paragraph in Conventions.
- Duplicated doc comments: `deliver` "False means nothing was" appears in both `outgoing-requests.ts` and `pending-requests.ts`, and the `idle()` "Resolves 0 once…" wording is repeated four times.
- `/** Injected so expiry can be exercised without a wall clock. */` repeated on four option types.
- `get sock` "Replaced on reconnect" in `session.ts`, which restates `PduTransport`.
5. **Scores.** The problem itself is hard (async link lifecycle, reconnect, protocol quirks); that gets no bonus below.
- **Navigation: 7.** At the "predictable" anchor: the AGENTS architecture map names every file by its question and the filenames match. It stops short of 9 because a behaviour like "a send during shutdown" spans `outgoing-requests.ts`, `link-life.ts`, `drain.ts` and `session.ts`, with no single landing file.
- **Locality: 6.** Between the middle and predictable anchors. The collaborators are cleanly split, but LinkLife's `stopping`/phase flags are read by OutgoingRequests at await boundaries, effects run in a different file from where they are decided, and HeldMessages is entered from the EventEmitter's `captureRejectionSymbol`. All of that is action at a distance.
- **Shape: 6.** Fan-out is bounded (Session has about 9 collaborators, each small), but some names lie: `ASCII` means GSM 03.38, `drain.ts` houses `IdleWaiters`, `requestPastDrain` is named for a caller, and the link starts in phase `up` before any bind.
- **Self-sufficiency: 5.** At the middle anchor. Comments are dense with spec citations and explain the WHY locally. Still, a reader without the domain needs README open for SMPP terms, and some rules exist only as index lines pointing at a decisions file.
- **Overall: 6.** Capped at 6 by self-sufficiency. The layout is good and the hard parts are really hard, but three of them (sections 1.1-1.3) need two or three files open at once.
SCORES nav=7 loc=6 shape=6 self=5 overall=6
## Draft B, mid seat
1. **Hardest places, hardest first**
1. **`src/link-life.ts:74` `LinkLife.transition()`, with `lose()` :148, `end()` :159, and `src/session.ts:263` `apply()` / :269 `run()`.** The table depends on two hidden variables besides the phase: the `stopping` flag and the `drops` counter. `lose()` sets the phase to `down` before it calls `end()`, and that is the only thing that stops `end()` counting the same drop twice. `'bound'` while stopping goes through `lose()` and ends up in `end()`. The effect order matters: `dropLink` clears held, incoming and outgoing before `emitClose`. The phase names mislead too. The doc at :14 says "`up`: a bound socket carries requests", but the phase starts as `up` (:60) on a socket nothing has bound yet, both on a server session and on a client before its bind. I only resolved that after reading `OutgoingRequests.requestPastDrain` (outgoing-requests.ts:85), where the bind path skips the link check. The table's JSDoc helps. The transitions themselves I had to trace by hand.
2. **Shutdown, spread over `src/session.ts:237` `unbind()` / :300 `drain()`, `src/drain.ts:96` `drain()` / :70 `answeringBudget()`, `src/outgoing-requests.ts:70` `request()` / :85 `requestPastDrain()`, and `src/held-messages.ts` `sendReceipt`.** To know whether a send is refused I had to hold five things at once: `isStopping() && canCarry()`, the "refused as closed further on" path through `LinkLife.refusal()`, the receipt's route past the drain, the `setImmediate` turn between the two waits, and the fallback for `shutdownTimeout: 0`. `IdleWaiters` lives in `drain.ts` but serves `SendWindow` and `HeldMessages`, so I went to the wrong file for it once. The comments explain each step, but no single place explains the whole sequence.
3. **`src/held-messages.ts:94` `offer()` / :117 `listenerRejected()` / :154 `keep()`.** Release path 3 depends on `Session.emit` being overridden (session.ts:78) to return `false` when a listener throws. Path 2 depends on `captureRejections` routing through session.ts:106 back into a `WeakMap` lookup by object identity. The `working` count is taken from `session.listenerCount('sms')` at keep time. That is action at a distance in both directions. The numbered "six ways" comment is what made it followable.
4. **`src/client.ts:228` `bindOn()`, :263 `initialAttempts()`, :286 `keepTrying()`.** These are three layers of session creation for `fromStart`, with abort listeners added and removed at different points. The comment at :249 says `close()` has to reach `stop()` before its first await. That rule depends on `Session.drain()` calling `apply('stopping')` synchronously (session.ts:301). The comment resolved it, but it is an ordering dependency across files.
5. **`src/pdu.ts:84` `resolveShortMessage()` / :113 `resolveBody()`.** The `CodingSource` idea (which of the two bodies gets to set `data_coding`) has about six branches: empty buffer, non-empty buffer, a string that encodes to empty, the command having no `short_message`, and a string `message_payload`. I had no domain background for why the payload may override `data_coding` only when `short_message` is empty. The type comment at :74 got me about half of it.
6. **`src/reassembly.ts:188` `trim()` with `src/expiring-groups.ts:70` `weigh()`.** `weigh()` returns evicted groups, possibly including the current one, and `trim` then uses `parts.size - 1` for that one. `ExpiringGroups` enforces its limits unevenly: `max` never, `maxWeight` only in `weigh`. Its three users each handle that differently: `HeldMessages` checks the weight itself, and `DlrMerger` runs two instances (`groups` + `spent`, dlr-merger.ts:150/:165). The class comment says all this, but I needed a second pass.
7. **`src/defs/encodings.ts:162` `messageClassEncoding()` / :182 `encodingByDataCoding()` / :151 `messageClassOf()`.** This is bit arithmetic over GSM 03.38 coding groups, which I have never seen. The comments cite the spec but I can't check them. It stayed partly opaque, but it is small and self-contained.
8. **`src/outgoing-requests.ts:114` `carry()` / :141 `attempt()`.** A recursive retry that shares one link budget, where a retry happens only when `retryOnNextLink && awaitsNextLink()`. Inside `carry()`, `const held = await waitForLink()` reuses the word "held", which elsewhere means `HeldMessages`. Readable once you know the link model.
2. **Least want to modify:** `LinkLife.transition()` together with `Session.run()`. Every lifecycle path goes through it (idle timeout, socket close, unreadable stream, unbind, close, rebind). The order of effects and the `stopping`/`drops` side state are invariants that nothing in the types enforces. A wrong effect order would show up as a leaked pending request or a double `close` event somewhere far away.
3. **Expected hard, found easy:** the wire codec. `defs/types.ts`, TLV read/write and `PduFramer` are mechanical, range-checked and uniform. The same goes for `PendingRequests`, `SendWindow`, `ReconnectLoop`, the linear check chain in `send-sms.ts`, and `udh.ts`. Receipt parsing in `dlr.ts` was clearer than I expected for an unfamiliar domain.
4. **Prose debt**
- **Needed, and cheap to find:**
- The charter's architecture list. It is accurate and maps one file to one question.
- The "GSM 7-bit is sent unpacked" section, needed for the 153/134 figures in `message.ts:129`.
- README "Bind direction" and "Receiving in depth", needed for the `data_sm` direction and for multipart being answered on arrival.
- README "Shutdown", needed to see why the drain waits on messages at all.
- **Needed, and costly:** nothing tells a newcomer what `esm_class`, `data_coding`, TON/NPI or `sar_*` are beyond scattered spec citations. I had to piece them together from the README and comments.
- **Stale prose:** `SessionOptions.shutdownTimeout` (session-options.ts:63) says "How long a drain waits for the requests already on the wire. 0 waits forever". That omits the wait on messages and the rule that 0 does not wait forever for them, which is exactly what `drain.ts` implements.
- **Told me nothing beyond the code:**
- The charter's defect table (0.4.0 history, no help reading today's code).
- The long test-fixture convention paragraph, for this read.
- The decision index titles, which I can't expand, and several of which repeat nearby code comments.
- `Injected so expiry can be exercised without a wall clock`, repeated in four option types.
- The duplicated `deliver()` doc on `OutgoingRequests` and `PendingRequests`.
- `/** Starts both timers over… */`-style restatements in `link-timers.ts`.
5. **Scores**
- **Navigation: 8.** Above 7, below 9. The charter's per-file map and concept-named files took me straight to the right place almost every time. The detours were `IdleWaiters` living in `drain.ts`, and receipt-sending split between `sms.ts` and `OutgoingRequests.requestPastDrain`.
- **Locality: 6.** Between 5 and 7. Collaborators are injected and the lifecycle effects are an explicit list. But `HeldMessages` and `IncomingRequests` call back into `Session` (`emit` override semantics, `listenerCount`, `close`), and correctness depends on synchronous ordering (`apply('stopping')` before the first await, the `setImmediate` in `drain`).
- **Shape: 7.** The Session hub's fan-out is wide but flat (about 9 collaborators, each small). A few names lie: the `up` phase before any bind, the `ASCII` encoding meaning GSM 03.38, the word "held" reused in `carry()`, and two different `onConnected` signatures (a `Session` in `ReconnectOptions`, a `Socket` in `ReconnectLoopOptions`).
- **Self-sufficiency: 7.** Comments give the why and the spec section at nearly every surprising line, so most units stand alone. The domain vocabulary (the `esm_class` and `data_coding` bit layouts) and the whole shutdown story needed the README beside the code.
- **Overall: 6.** Held down by Locality. The hard part (link life plus the drain) is localized and marked, but it takes rereads. A mid-level reader takes about two days to feel safe in `session`, `link-life` and `held-messages`.
- **Intrinsic difficulty:** high. An asynchronous protocol session with reconnect, a send window, reassembly and a graceful drain, on a domain the reader doesn't know. That earns no bonus here.
SCORES nav=8 loc=6 shape=7 self=7 overall=6
## Draft B, senior seat
1. **Hardest places, ranked hardest first**
1. **The shutdown path**: `src/session.ts:237` (`unbind`), `:254` (`close`) and `:300` (`drain`); `src/drain.ts:96` (`drain`), `:70` (`answeringBudget`) and `:65` (`leftOf`); `src/outgoing-requests.ts:70` (`request`).
- To answer "what does a send do during shutdown?" I had to hold four files at once. `LinkLife.stopping` is set by `apply('stopping')`. `request()` refuses only when `isStopping() && canCarry()`. Otherwise it relies on `awaitsNextLink()` going false because `retrying()` checks `!stopping`, which I had to hunt for in `link-life.ts:132`.
- The two drain budgets differ in a way only the code shows. Messages fall back to `responseTimeout`; requests get `leftOf(deadline)`, clamped to at least 1 ms. There is also an ordering dependency on a `setImmediate` turn between the two waits (`drain.ts:108`).
- The comments are correct but terse. I resolved it after two reads, plus the README "Shutdown" section.
2. **The held-message lifecycle**: `src/held-messages.ts:94` (`offer`), `:117` (`listenerRejected`) and `:154` (`keep`); `src/session.ts:106`; `src/sms.ts:91-93` and `:151-161`.
- A hold ends in six ways, and they are spread across three files. `Session`'s `captureRejectionSymbol` reaches into `held` by identity, through a `WeakMap`. `working` is a snapshot of `listenerCount('sms')` taken when the message is kept.
- The numbered "1–6" comments are what resolved it. Without them this was action at a distance.
3. **The link state machine**: `src/link-life.ts:74` (`transition`), `:148` (`lose`) and `:159` (`end`).
- `lose()` sets `down` and then calls `end()`, which rereads `attached()` (now false). `drops` is incremented in two places.
- The initial `linkPhase = 'up'` (`:60`) contradicts the type doc at `:13-17`, which says `up` means "a bound socket carries requests". A fresh client or server socket is `up` before any bind.
- Session's `bound()` (records the bind) and LinkLife's `'bound'` event (a rebind was answered) share a name and mean different things. This stayed partly opaque until I traced `comeBackUp` (`session.ts:359`).
4. **Reassembly under the octet cap**: `src/reassembly.ts:111` (`collect`) and `:188` (`trim`), with `src/expiring-groups.ts:70` (`weigh`).
- The segment is inserted before `trim`. `weigh` can evict the current group itself, and `answered = parts.size - 1` then excludes the refused segment from the loss.
- `ExpiringGroups` enforces max, timeout and weight differently: `full` is only a flag, `weigh` evicts, `set` never does. Its own doc comments make that explicit, which resolved it.
5. **Encoding a body under `data_coding`**: `src/pdu.ts:84` (`resolveShortMessage`) and `:113` (`resolveBody`).
- `CodingSource` decides whether `short_message` or `message_payload` may overwrite `data_coding`. An empty encoded buffer flips the source to `message_payload`, and a Buffer `short_message` of length 0 does the same.
- I needed the decision titles in the charter to see why. The branches are stateful rather than hard, but I reread them three times.
6. **The client's first connect**: `src/client.ts:333` (`client`), `:286` (`keepTrying`), `:263` (`initialAttempts`) and `:228` (`bindOn`).
- There are two `ReconnectLoop`s: one outside any session for `fromStart`, and one inside each session. Each attempt builds and discards a whole `Session`.
- `bindOn` relies on `close()` reaching `stop()` before its first await (the comment at `:249`). That ordering invariant lives in `session.ts:300` (`drain` calling `apply('stopping')` synchronously).
- The comment named the ordering rule; checking it held took a look at `session.ts`.
7. **`DlrMerger.open`/`close`**: `src/dlr-merger.ts:150` and `:165`.
- `close()` means "delete, then mark spent". It runs on completion, on expiry, on eviction and on a reused base, and the delete-then-set on `spent` is only there to refresh its deadline.
- A second `ExpiringGroups<true>` used as a tombstone set is clever but not named as one. The class doc resolved it.
8. **Server hook composition**: `src/server.ts:208` (`handleRequest`) with `src/incoming-requests.ts:89` (`handle`) and `:147` (`unhandled`).
- What happens to a rebind or a pre-bind `unbind` is decided half in each file, through `false` return values. `boundAs === undefined` makes `bindAllows` return true.
- I resolved it by reading both. A smaller cost of the same kind: `pdu.ts:249` passes `sm_length` as the `length` argument to every wire type's `read`, and only `buffer` uses it. That implicit coupling depends on wire order.
2. **The unit I would least want to modify: `OutgoingRequests`** (`src/outgoing-requests.ts:70-168`)
- It has three entry points: `request`, `requestPastDrain` and `requestOnCurrentLink`. Each skips a different subset of checks (drain refusal, waiting for a link, the window).
- Every sender in the library picks one of them by name: `enquire_link`, `sendSms`, receipts from `sendDlr`, bind and unbind.
- The retry recursion in `carry` depends on `LinkLife.awaitsNextLink()`, `pending.settle` ordering, and window release in `finally`.
- Whether a request may be resent (goal 2) is decided here, and it hinges on the `retryOnNextLink` flag. A wrong edit silently duplicates billed traffic.
3. **Expected hard, found easy**
- The codec: `defs/types.ts` wire types, `PduFramer`, and `parseTlvs`/`writeTlvs` with the typed `Tlvs`.
- GSM 03.38 escaping, `encodingByDataCoding`, receipt text parsing (`dlr.ts`), `sms-id.ts` normalisation, `ReconnectLoop` backoff, and `udh.ts` IE walking.
- Each is self-contained, total, and commented at the exact surprising line.
4. **Prose debt**
- **Needed**:
- The README "Shutdown" and "Sends and the link" sections, to confirm the drain semantics I was reverse-engineering. They cost a scroll through a 773-line README.
- The charter's decision index. The titles hinted at intent ("One owner decides whether a link can carry a request, and a bind is what makes it one"), but I was forbidden from `decisions.md`, so several stayed claims I could only check against code. The one above contradicts LinkLife starting `up`.
- The GSM-unpacked section in the charter, to trust `segmentUnits` (`message.ts:343`).
- **Defaults live in five places**: `client.ts:45`, `server.ts:403`, `session-options.ts:74`, `reconnect-loop.ts:5` (`backoffDefaults`) and `reassembly.ts:45` (`defaultMaxOctets`, duplicated by `maxHeldOctets`). "Where is the default of X" is a grep.
- **Told me nothing new**:
- The charter's architecture table mostly restates file names.
- Its long paragraph on test conventions is irrelevant to `src/`.
- `session.ts:201` ("Sends a request and resolves with the peer's response") restates the code.
- `reconnect-loop.ts:47` ("Read through a method: stop() can land while an attempt is awaiting") hides its real reason: it defeats TS narrowing.
- `client.ts:101` and `reconnect-loop.ts:84` and `:140` re-implement `errorFrom` inline.
5. **Scores**
The problem's intrinsic difficulty is high: an interop-heavy protocol, reconnect, and correct-accounting shutdown. It gets no bonus here.
- **Navigation: 7.** Against "Predictable": file names and the charter's table put a symptom in the right file first try. What keeps it from 8 is that defaults and the shutdown rules are spread across five files each.
- **Locality: 5.** Against "Honest middle": LinkLife's phase and generation are read from Session, OutgoingRequests, IncomingRequests and HeldMessages. Understanding shutdown or a held message means holding 4–5 files and a synchronous-ordering invariant.
- **Shape: 6.** Between 5 and 7: most names tell the truth. The named lies are the `up` phase on an unbound socket, `'ASCII'` for GSM 03.38, "bound" meaning two things, `DlrMerger.close` meaning "tombstone", and three near-synonym request methods. Session's constructor fans out to nine collaborators.
- **Self-sufficiency: 7.** Against "Predictable": the one-line why-comments at the surprising lines resolved nearly every question. I needed the README only for the drain semantics, and the decision titles were claims I could not verify inside the code.
- **Overall: 6.** Capped by locality at 5 + 1. The hard parts are marked but not localized: they are the problem's own difficulty, spread across collaborators that share link state.
SCORES nav=7 loc=5 shape=6 self=7 overall=6
## Draft B, architect seat
**Comprehension panel: Architect, inherited. @larvit/smpp, draft-b, whole project**
Process note: I read `AGENTS.md` in the same call as `README.md` during step 1, so the map below was not formed from the README and file tree alone. I opened no test file.
## 1. Map from the README and the file tree (verbatim)
Top-level areas I believe exist:
1. **Public surface**: `index.ts`.
2. **Endpoints**: `client.ts` (connect, bind, reconnect-from-start) and `server.ts` (listener, auth, bind answering).
3. **Session core**: `session.ts`, `session-options.ts`, `bind-direction.ts`.
4. **Link lifecycle**: `link-life`, `link-timers`, `reconnect-loop`, `pdu-transport`, `drain` (shutdown?).
5. **Outbound requests**: `outgoing-requests`, `pending-requests`, `send-window`, `send-sms`, `unanswered-error`.
6. **Inbound messages**: `incoming-requests`, `sms.ts` (the handle), `held-messages`, `reassembly`, `concat`, `udh`, `message-body`.
7. **Receipts**: `dlr`, `dlr-merger`, `sms-id`.
8. **Codec**: `pdu`, `pdu-framer`, `pdu-refusal`, `retained-pdu`, and `defs/*` as pure spec tables.
9. **Text**: `message.ts` (encode, split, `smppTime`) and `defs/encodings`.
10. **Plumbing**: `result`, `log`, `error-from`, `uuid`, `expiring-groups`.
Names that do not give their purpose:
- `drain.ts`
- `retained-pdu.ts`
- `expiring-groups.ts`
- `error-from.ts`
- `link-life` vs `link-timers`
- `outgoing-requests` vs `pending-requests` vs `send-window`: three names for "requests we sent"
- `held-messages` vs `reassembly`: both "held" inbound state
Expected from the README but not there: the goal-9 store interface. The README says it has not shipped, so that costs nothing. `interop-tests/` and `benchmarks/` sit outside `src`/`test`.
## 2. Where the map was wrong, and what each correction cost
| Correction | Cost |
| --- | --- |
| `defs/` is not just tables. `defs/types.ts` (684 lines) and `defs/tlvs.ts` are half the wire codec: read, write and size for every field, plus the TLV stream. `pdu.ts` is only the envelope and the body rules. | Moderate. "Where is a field written" lands in `defs`, not `pdu`. |
| `drain.ts` also holds `IdleWaiters`, which `SendWindow` and `HeldMessages` import. The send window depends on the shutdown module. | Low, but it breaks the "which way do imports point" picture. |
| `held-messages` is not a reassembly buffer. It is the inbound messages the *application* has not answered yet. | Moderate: a name-level misread. |
| `link-life` is liveness plus a queue: the phase state machine, and where a request waits for the next link. | Low. |
| Bind handling is not in the session. `server.ts` does it through the `onRequest` hook (`handleRequest`), and `client.ts` has its own `bind()`. | Moderate: two homes for one protocol step. |
| `udh.ts` reads UDHs. Writing one is hand-rolled in `message.ts:114` (`0x05,0x00,0x03,…`), and the outbound reference counter `ConcatReference` sits in `udh.ts`. | Low. |
| `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`. | Low. |
| `session.ts` is thinner than I expected (424 lines): a coordinator, not a god class. | Pleasant surprise. |
## 3. Fan-out, level by level
- **L0, the README:** about 10 areas. Fine.
- **L1, `src/`:** 37 flat files plus `defs/` (7). **This is the worst level.** Nothing in the layout groups the 10 areas, so only filenames and the `AGENTS.md` list stand in for directories.
- **L2, `session.ts`:** 10 collaborators (DlrMerger, HeldMessages, IncomingRequests, LinkLife, LinkTimers, OutgoingRequests, PduTransport, ReconnectLoop, ConcatReference, drain), plus 6 LinkEffects and 5 LinkEvents. At the edge but legible.
- **L3:**
- `IncomingRequests`: 8 (Reassembler, DlrMerger, HeldMessages, Session, bind-direction, dlr, concat, sms-id).
- `OutgoingRequests`: 4.
- `HeldMessages`: 5.
- `server.ts`: about 6.
- `client.ts`: about 8 local functions and a second `ReconnectLoop`.
- **`defs/`:** 7. Fine.
## 4. Names
**One name over two concepts:**
- **"unanswered"** means inbound messages the application has not answered (`drain.ts:52` `messagesUnanswered`, README "Unanswered messages"). It also means our requests the peer did not answer (`UnansweredError`, `SendSmsResult.unanswered`, `SendDlrResult.unanswered`). The drain waits on both kinds in one function.
- **"held"** means `HeldMessages` (inbound, unanswered by the app). It also means a request waiting for a link: `link-life.ts:191` "holding a request until a link is back", and `outgoing-requests.ts:119` `const held = await waitForLink()`.
- **`answered`** in `createSms` (`sms.ts:81`) is a mutable `{ smsId }` box, while `handlers.answered` is the release callback. They are two things on adjacent lines.
**Names that mislead:**
- `EncodingName 'ASCII'` is GSM 03.38.
- `drain.ts` houses the general `IdleWaiters`.
- `defs/` houses the codec.
- `ExpiringGroups` expires nothing itself. Its own doc at `expiring-groups.ts:19` says owners sweep and only `weigh()` evicts.
- `session-options.ts:63` documents `shutdownTimeout` as "how long a drain waits for the requests already on the wire". It also bounds the messages half. That comment is false on exactly the 3am path.
**One concept, many homes:**
- **Defaults live in five places:** `client.ts:45`, `server.ts:403` (idleTimeout 40 000 here vs 2 × enquireLink in the client), `session-options.ts:74`, `reconnect-loop.ts:5` `backoffDefaults`, and `reassembly.ts` `defaultMaxOctets`. The last duplicates `defaults.maxHeldOctets` (same 64 MiB).
- **Two near-identical collectors:** `send-sms.ts:274` `collectSent` and `sms.ts:188` `collectReceipt`.
- **Five concat names:** `Concat`, `ConcatInfo`, `concatOf`, `concatInfo`, `ConcatReference`, spread over `concat.ts` and `udh.ts`.
## 5. What I would restructure, ranked
1. **Group `src/` into about five directories:** `link/`, `outbound/`, `inbound/`, `codec/`, `receipts/`. The seams already exist in the imports; only the layout hides them.
2. **Give defaults one home:** a single `defaults` module, with the client/server differences expressed as named overrides.
3. **Split "unanswered" into two words**, for example "unreleased" for app-side messages and "unanswered" for peer-side requests. Move `IdleWaiters` out of `drain.ts`.
4. **Put UDH read and write in one file,** together with `ConcatReference`, and move `decodeSegments` next to `messageOctets`.
5. **Rename `defs/types.ts`** to what it is, the wire field codec, or move it beside `pdu.ts`.
**What the structure gets right:**
- `LinkLife.transition()` is one explicit table returning effects, and `Session.run` (`session.ts:269`) is the only interpreter of them.
- Collaborators take narrow option objects.
- The result-everywhere rule is applied uniformly.
- `drain()` names its two halves, and `report()` logs which half was left over.
- Comments cite the SMPP section or the operator that forced each odd rule.
## 6. The 3am question
A graceful shutdown hangs until `shutdownTimeout` although `sendResp()` was called on everything.
**Route, cold:** `Session.close` (`session.ts:254`) → `Session.drain` (`session.ts:300`) → `drain()` (`drain.ts:96`). That took about 3 minutes, because the filename and the function name agree. Deciding which half took about 10 more minutes:
- With every `sendResp` done, `HeldMessages.idle` should settle. Release happens at `held-messages.ts:170` via `sms.ts:159`.
- So the right unit is the second half: `OutgoingRequests.idle` (`outgoing-requests.ts:109`) → `SendWindow.idle`/`unfinished` (`send-window.ts:65`).
- That means a request of ours is still in flight. Typically it is the `sendDlr()` receipts that `requestPastDrain` let through, or a heartbeat `enquire_link` the peer is not answering.
- Each such request is bounded by `responseTimeout` (30 s), which is longer than `shutdownTimeout` (5 s).
**Second suspect:** `sendResp` returned `err` because the write failed, and the application ignored it. The message is then never released (`sms.ts:159` releases only on success).
The log lines `drain - shutting down with requests unfinished` and `drain - shutting down with messages unanswered` separate the two. The false comment at `session-options.ts:63` costs a detour.
**Where it rots first:**
- **`HeldMessages`:** six numbered exits. A listener count taken via `listenerCount('sms')` at `keep()` (`held-messages.ts:154`) and decremented through `captureRejections` routed from `session.ts:97`. A `WeakMap` keyed on the handle's identity, and a back-reference to the half-constructed `Session` (`session.ts:137`).
- **The link-generation checks:** repeated in `sms.ts` (`lostLink`), `incoming-requests.ts:90/97` and `held-messages.ts:95`. Each is a local copy of one invariant.
**Where the next two features land:**
- **Goal-9 store:** behind `ExpiringGroups`, whose three owners (`DlrMerger`, `Reassembler`, `HeldMessages`) each use a different subset of its semantics: sweep callbacks, `weigh()` eviction, `full`. A store has to replicate all three contracts.
- **Per-PDU rate-limit hook (goal 7):** `OutgoingRequests.carry` (`outgoing-requests.ts:114`), between `window.acquire` and `attempt`. That place is clean and local.
- A custom alphabet would instead ripple through the closed `EncodingName` union: `message.ts:14` `segmentUnits`, `dataCodingByEncoding`, and the `send-sms` checks.
## 7. Hardest places, ranked
1. **`src/held-messages.ts:40`, `HeldMessages`:** `offer` :94, `listenerRejected` :117, `keep` :154, `release` :170. It has six exits, identity-keyed release, a listener-count countdown, and is coupled to the `Session` emitter.
2. **`src/outgoing-requests.ts:70/85/101`, `request` / `requestPastDrain` / `requestOnCurrentLink`:** three entry points that differ in which gates they skip (drain refusal, link wait, window). The bind bypass is buried inside `requestPastDrain`, and the `isStopping() && canCarry()` gate needs a second read.
3. **`src/link-life.ts:74`, `LinkLife.transition`,** with `lose` :148 and `end` :159. A `stopping` flag orthogonal to the phase, `'bound'` while stopping turning into a loss, and effect order that matters in `Session.run` (`session.ts:269`, where `dropLink` clears four stores).
4. **`src/drain.ts:70/96`, `answeringBudget` and `drain`:** `0` means forever except for the messages half, plus a `setImmediate` turn whose job is to catch a receipt issued right after the last answer.
5. **`src/reassembly.ts:188`, `Reassembler.trim`,** with `collect` :111. The eviction arithmetic (`parts.size - 1` when the current group is itself evicted) is correct but has to be derived.
6. **`src/expiring-groups.ts:19/70`, `ExpiringGroups.weigh`:** it evicts, `set` does not, and owners must sweep. Its callers' correctness depends on remembering which.
7. **`src/pdu.ts:84/113`, `resolveShortMessage` / `resolveBody`:** which field the `data_coding` describes, and when detection may overwrite it.
8. **`src/client.ts:263/286`, `initialAttempts` / `keepTrying`:** a second `ReconnectLoop`, with a fresh `Session` per attempt, for `fromStart`.
**Single unit I would least want to modify:** `HeldMessages` (`src/held-messages.ts`).
## 8. Intrinsic difficulty
This is an SMPP session layer with reconnect, graceful drain, bounded reassembly, receipt merging and a hand-rolled wire codec. The problem itself is moderately high in difficulty, and gets no bonus in the scores.
## 9. Scores
- **Navigation 7:** matches the "predictable" anchor. Descriptive filenames and function names took me from the symptom to `drain()` in minutes, and `report()` logs which half stalled. The flat 37-file `src/` and the false `shutdownTimeout` comment keep it from 8.
- **Locality 6:** between "honest middle" and "predictable". `HeldMessages` and `IncomingRequests` call back into `Session` (emit, `sendReturn`, `close`), the link-generation invariant is copied into three places, `Session.run` effect order is load-bearing, and defaults live in five files.
- **Shape 6:** between the anchors. L2 and L3 fan-out is bounded and most names tell the truth. `src/` fans out to 37 files, and "unanswered", "held", `'ASCII'`, `drain.ts` and `defs/` each name something other than what they hold.
- **Self-sufficiency 7:** the "predictable" anchor. Invariants are stated at the code (ExpiringGroups' contract, the six exits, SMPP section citations), and I did not need a second document open. It misses 8 on one false option comment and a few budget rules (`answeringBudget`) that need rereading.
- **Overall 6:** capped at the lowest dimension plus one (7). It sits at 6 because the hardest code (the held-message flow and outbound gating) is exactly where locality is weakest.
SCORES nav=7 loc=6 shape=6 self=7 overall=6
@@ -1,658 +0,0 @@
# Round 2: drafts C and D
## Draft C, junior seat
1. **Hardest places, ranked hardest first**
1. **`src/session.ts:336-404` (`drain`, `dropSocket`, `linkLost`, `end`), `comeBackUp` at `:303`, and `src/session/link-life.ts:17-82` (`LinkLife`'s phase plus its predicates).** Link state is a four-value `Phase` plus a separate `stopped` flag. Seven predicates read it: `isAttached`, `isUp`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`. On top of that, `OutgoingRequests.canCarry()` adds `!sock.destroyed`. To follow `comeBackUp`'s `!this.link.retrying() || !this.link.isUp()` (`:319`) or `drain`'s two `canCarry()` checks, I had to hold every phase transition at once. `linkLost` is order-dependent ("Read first: a `disconnected` listener may close() the session"), so a synchronous listener re-enters the session in the middle of the method. The comments resolved each line on its own. The whole state machine never became clear to me; I would need to draw it.
2. **`src/session/outgoing-requests.ts:83-150` (`request`, `carry`, `attempt`).** The three lanes, the `for(;;)` retry loop, `window.release()` in a `finally`, and `pending.wait()` registered before `write()` all interact. The loop-exit comment at `:118` ("Until the link is dropped it admits the retry straight back onto the dead socket, and the loop spins") took three rereads. The `Lane` doc comment at `:20-26` rescued the lanes. The retry exit stayed half-opaque.
3. **`src/session/incoming-requests.ts:103-271` (`handle`, `route`, `onDelivery`, `onMessage`, plus `refusedSegmentStatus` at `:28`).** This is where the domain costs the most. `data_sm` routes by `carriedAs`. `deliver_sm` might be a receipt or a message, and `onDelivery` falls through to `onMessage`. The status codes come as a family: `ESME_RX_P_APPN`, `ESME_RX_T_APPN`, `ESME_RTHROTTLED`, `ESME_RINVTLVVAL`, `ESME_RINVESMCLASS`. The `arrivedOn` socket-identity check at `:111` is state compared across an `await`. The flow reads cleanly, but I only knew why each status was right after reading README "Receiving in depth" and "Server in depth".
4. **`src/messages/reassembly.ts:110-206` (`Reassembler.collect`, `trim`) with `src/messages/expiring-groups.ts:70-88` (`weigh`).** `weigh()` can evict the group currently being added to. `trim` then counts `parts.size - 1` for that group, because the segment just added is refused and so is not lost. The contract is split across two classes: `ExpiringGroups` "enforces neither max nor timeout itself", so every owner must remember to call `full` or `takeExpired`. It resolved after reading the `ExpiringGroups` doc comments. Action at a distance, but it is marked.
5. **`src/messages/dlr.ts:157-238` (`messageType`, `receiptStatus`, `dlrFromPdu`).** A four-value `MessageType` is derived from `esm_class` bits and then from a TLV. The TLV state and the body's state take precedence over each other in different ways. `statusId` falls back to `UNKNOWN`, and an `unmarked` PDU without both an id and a state is not a receipt. The comments cite spec sections (5.3.2.26, Appendix B) that I cannot open. The README `Dlr` field table is what finally made the output shape make sense.
6. **`src/client.ts:219-322` (`bindOn`, `initialAttempts`, `keepTrying`).** There are two routes into `ReconnectLoop`: the session's own, and a second one here that builds a fresh `Session` per attempt. There is closure state (`lastErr`, `settled`) and abort-listener bookkeeping. The comment at `:241` ("close() must reach the loop's stop() before its first await") depends on how `Session.close` is ordered inside, in another file. It stayed partly opaque.
7. **`src/wire/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`).** `CodingSource` decides whether `short_message` or `message_payload` owns `data_coding`. The cases are Buffer or string, empty or not, crossed with a string TLV being present. That is four or more branches returning objects that are almost the same. The `CodingSource` doc comment helped, but only after I had read `messageOctets()` in `message-body.ts`.
8. **`src/defs/encodings.ts:151-190` (`messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`).** Bit masking over GSM 03.38 coding groups, which I had no context for. `messageClassEncoding` has a `//` comment and a `/** */` comment stacked on top of it that say different things. It stayed opaque, and I would trust the tests over my reading.
2. **The unit I'd least want to modify:** `Session.linkLost`, `dropSocket` and `end` (`src/session.ts:366-404`) together with `LinkLife`. They are re-entered from the transport (close, error, unreadable), from `LinkTimers.onIdle`, from `comeBackUp`, from `unbind` and `close`, and synchronously from application listeners (`disconnected`, `close`). The correctness depends on call order and on idempotence guards (`drop()` returning false, `end()` returning false). None of that is visible at any single call site.
3. **Expected hard, found easy:**
- The codec in `defs/types.ts`: 684 lines, but it is one pattern repeated (read, size, write, all returning Results).
- `PduFramer`.
- The no-throw `Result` convention, which is applied the same way everywhere.
- `send-sms.ts`: `checkOptions` is a flat chain of guards.
- `bind-direction.ts`.
- The folder layout. The AGENTS.md architecture map matched the files one to one, and a one-line purpose per file made cold navigation fast.
4. **Prose debt**
- **What I needed and what it cost:**
- The basic domain vocabulary: ESME vs SMSC/MC, bind types, what `deliver_sm` doubles as, `esm_class`, `data_coding`, UDH vs `sar_*`, `registered_delivery`. There is no glossary anywhere. I rebuilt it from README "Receiving in depth", "Delivery receipts" and "Bind direction", which cost a pass over a 764-line README with a lot of jumping.
- The AGENTS section "GSM 7-bit is sent unpacked", which was essential for `segmentUnits` in `message.ts`.
- Many code comments cite SMPP section numbers with no summary, so they are pointers I could not follow.
- The AGENTS decision index names choices, for example "Every request leaves through one `request()`, in one of three lanes", whose reasoning is in `docs/decisions.md`, which was out of bounds. The titles alone helped a little.
- I did not open any test.
- **Prose that added nothing the code didn't already say:**
- The seven `declare` listener lines in each emitter class.
- `OutgoingRequests.deliver` ("False means nothing was") and `PendingRequests.deliver`, both restating their code.
- README's "Everything exported" table, which repeats `index.ts`.
- The AGENTS Conventions paragraph about test fixtures, for reading `src/`.
- The 0.4.0 defect table, which is history and says nothing about the current code's structure.
5. **Scores**
- **Navigation: 7.** It sits at "predictable": the `session/`, `messages/`, `wire/`, `defs/` split plus the per-file map in AGENTS.md got me from a symptom to a file on the first try. It stays below 8 because concatenation logic is split three ways (`concat.ts`, `udh.ts`, `reassembly.ts`), refusal statuses are split between `pdu-refusal.ts` and `incoming-requests.ts`, and `udh.ts` holds `ConcatReference`.
- **Locality: 6.** It is between "honest middle" and "predictable". The hard parts are marked, but `IncomingRequests` holds the `Session` and calls back into it (`emit`, `sendReturn`, `close`, `sock`, `linkEnd`). `LinkLife` is shared by `Session` and `OutgoingRequests`. `ExpiringGroups` depends on its owners to sweep. `linkLost` is re-entrant through listeners.
- **Shape: 6.** Files are small and fan-out is bounded, but some names mislead:
- The GSM7 codec is called `ascii`.
- `ExpiringGroups<true>` is used as a "spent" set.
- `linkLost()` means something different in `Session`, `OutgoingRequests` and `IncomingRequests`.
- `refusal()` returns an `Error` on `LinkLife` and an `ErrorName` on `IncomingRequests`.
- `IdleWaiters.settle()` wakes waiters whatever the count reads.
- **Self-sufficiency: 5.** Honest middle. The comments are dense and often state invariants. But for a reader with no SMPP background, the domain terms and bare spec citations mean the README has to stay open beside `incoming-requests.ts`, `dlr.ts` and `encodings.ts`.
- **Overall: 6.** Capped at self-sufficiency plus one. The layout and conventions are clearly cared for; what costs a junior is the lifecycle state machine and the unexplained domain.
- **Intrinsic difficulty:** high. It is an asynchronous protocol session with reconnect, drain, windowing and reassembly under hostile input. That gets no bonus in the scores above.
SCORES nav=7 loc=6 shape=6 self=5 overall=6
## Draft C, mid seat
1. **Hardest places, hardest first**
1. **`src/session.ts:303-404`: `Session.comeBackUp` / `drain` / `dropSocket` / `linkLost` / `end`.** Whether the link is alive is held in four places:
- `LinkLife.phase`, which is `binding`, `up`, `down` or `ended`
- `LinkLife.stopped`
- `ReconnectLoop.halted`
- `transport.sock.destroyed`
Order matters in several spots, and only comments say so. `linkLost` reads `retrying()` before the drop because a `disconnected` listener may call `close()`. `comeBackUp` checks `!retrying() || !isUp()` after `bind()`. That only makes sense once you find that `bind()` reaches `session.bound()`, which calls `link.open()` out of sight. `canCarry()` (`outgoing-requests.ts:58`) asks both `link.isUp()` and `sock.destroyed`, and nothing explains why both are needed. The comments helped, but I had to trace the state by hand and it is still not fully clear to me.
2. **`src/session/outgoing-requests.ts:83-150`: `OutgoingRequests.request` / `carry` / `attempt`.** The loop in `carry` has three waits: the link budget, a window slot, and the response. The window slot is released in a `finally`, and a retry is allowed only when `attempt.retry && link.awaitsNextLink()`. You need LinkLife's phase logic in your head (`link-life.ts:71-82`) to see why the loop ends. The comment "the loop spins" warns about it but does not explain it. The three lanes are well documented in the `Lane` type's comment (line 25). This resolved, slowly.
3. **`src/wire/pdu.ts:84-136`: `resolveShortMessage` / `resolveBody`.** The code decides whether `short_message` or `message_payload` owns `data_coding`. An empty Buffer means `message_payload`, and a string body gets encoded and can overwrite `data_coding`, but only for one of the two sources. I had no domain context and read it three times. The `CodingSource` comment helped, and the README "Building" bullets confirmed what it is meant to do.
4. **`src/session/incoming-requests.ts:103-271`: `handle` / `route` / `onMessage` / `refusedSegmentStatus`.** A `data_sm` becomes `submit_sm` or `deliver_sm` depending on `linkEnd` (via `standsInFor`). A `deliver_sm` that is not a receipt falls through to `onMessage`. The status codes (`ESME_RX_T_APPN`, `ESME_RTHROTTLED`, `ESME_RINVTLVVAL`, `ESME_RINVESMCLASS`) are domain rules I cannot check. The class also calls back into `Session` through `sock`, `emit`, `sendReturn` and `close`. The socket-identity check after the `onRequest` await was clear once the comment was read. The status choices stayed opaque; I trusted README "Receiving in depth".
5. **`src/messages/reassembly.ts:110-206` with `expiring-groups.ts:70-88`: `Reassembler.collect` / `trim` and `ExpiringGroups.weigh`.** The rules are split between the two classes:
- ExpiringGroups enforces neither max nor timeout, and its owners must.
- `set()` resets weight to zero.
- `weigh()` may evict the key you are weighing.
- `trim` works out `answered = parts.size - 1` for the current group.
The comments state each rule, but I needed all of them at once. This resolved.
6. **`src/messages/dlr-merger.ts:105-185`: `DlrMerger.collect` / `open` / `spend`.** There are two stores (`groups` and `spent`), and `spend()` is the exit for four different situations: completion, expiry, reuse and eviction. The class docblock and the `severity` comment explain it. This resolved.
7. **`src/defs/encodings.ts:162-190`: `messageClassEncoding` / `encodingByDataCoding`.** This is bit-level decoding of `data_coding`. The comments say which bits are which but not the table behind them. The code is short, but it stayed opaque without the GSM 03.38 spec.
8. **`src/client.ts:220-322`: `bindOn` / `initialAttempts` / `keepTrying`.** A second `ReconnectLoop` is built outside the session for `fromStart`. `bindOn` depends on `close()` reaching `stop()` before its first await, which the comment at line 241 states. There are also two abort listeners with different lifetimes. This resolved with effort.
I did not open any tests.
2. **Unit I would least want to modify:** the session lifecycle cluster, `Session.linkLost` / `dropSocket` / `end` / `comeBackUp` together with `LinkLife`. Every change there interacts with the reconnect loop's own stopped flag, with the drain's `canCarry()` checks before and after it waits, and with whatever an event listener does during `emit`. Tests would be the only way to know a change is safe.
3. **Expected hard, found easy:**
- `PduFramer`
- the parse path in `pduToObj`/`parsePdu`
- `PendingRequests`
- `SendWindow`
- `ReconnectLoop` backoff
- the listener guards that stop an application listener's throw or rejection from escaping (the no-throw rule)
- the `defs/` tables
- `defs/types.ts`, which is 684 lines but repetitive and predictable
The rule that nothing throws makes every call site easy to read.
4. **Prose debt**
- **Needed:**
- README "Shutdown" and "Sends and the link", to understand the lanes and the drain.
- README "Receiving in depth", for which way a `data_sm` goes and the throttle statuses.
- The AGENTS architecture map, which is accurate and was the best way in.
- The AGENTS decision index lines, such as "Every message is answered on arrival" and "One owner decides whether a link can carry a request". They gave intent cheaply because each is one line.
- **Cost to find:** low, because the map and the index point to the right places. The code's references to spec sections (e.g. "SMPP 3.4 5.2.19") assume a document I do not have.
- **One false claim:** AGENTS says "`wire` uses `defs`, `messages` uses `wire`", but `wire/pdu.ts:10` imports `decodeMessage` and `encodeBody` from `messages/message.ts`. So wire and messages depend on each other.
- **Told me nothing:**
- the AGENTS 0.4.0 defect table (history, not needed to read the current code)
- most of the test-convention prose in AGENTS
- the README feature bullets
- one-line docstrings that restate the method name, such as `isStopped` and `get sock`
- **Duplicated code:** `quoted()` is duplicated in `bind-direction.ts` and `session-options.ts`. `collectSent` and `collectReceipt` are near-copies.
5. **Scores**
- **Navigation: 7 (Predictable).** The AGENTS file map matches the layout, and the `session/` / `messages/` / `wire/` grouping took me straight to the right file. It falls short of 9 because answering a request is split across `server.handleRequest`, `IncomingRequests.unhandled` (`ESME_RALYBND`) and `Session.refuse`.
- **Locality: 6 (between 5 and 7).** The collaborators are small and injected. But link liveness is spread over `LinkLife`, `ReconnectLoop.halted` and `sock.destroyed`, and three comments carry order rules: "Read first", "must reach stop() before its first await", and "the loop spins". `IncomingRequests` also reaches back into `Session`.
- **Shape: 7 (Predictable).** Fan-out at each level is bounded, and the `Session` constructor wires seven named collaborators. Some names mislead:
- the GSM codec is called `ascii` (`encodings.ts:45`)
- `idle` means three different things: `IdleWaiters`, `ExpiringGroups.idle()` and the `LinkTimers.idle` timer
- the near-synonyms `stop`, `end`, `close`, `release`, `linkLost` and `dropSocket` blur which one is final
- **Self-sufficiency: 7 (Predictable).** Almost every non-obvious branch carries a one-line reason, often with a spec section. The lanes, the drain and the refusal statuses still needed the README open beside the code.
- **Overall: 6.** It is capped by Locality. The lifecycle corners are marked, but they are not contained.
The problem is hard in itself: a protocol full of peer quirks, plus async lifecycle with reconnect and drain. That gets no bonus here.
SCORES nav=7 loc=6 shape=7 self=7 overall=6
## Draft C, senior seat
1. **Hardest places, ranked**
1. **`src/session.ts:303-404`, the `Session` lifecycle: `comeBackUp`, `drain`, `stop`, `dropSocket`, `linkLost`, `end`.** Whether the session is still alive is spread across three places: `LinkLife`'s phase and its separate `stopped` flag, `ReconnectLoop.halted`, and `link.end()`. To follow any one of these methods I had to hold all three. `comeBackUp:319` tests `!retrying() || !isUp()`. That only makes sense once you know `isUp()` got set as a side effect: the `onConnected` callback in `client.ts:bind` calls `session.bound()`, which calls `link.open()`. The ordering is load-bearing in several places. `linkLost:379` has to read `retrying()` before the drop. `end()` calls `dropSocket()`, which is also a guard. `client.ts:241` says "close() must reach the loop's stop() before its first await". The comments at the call sites marked each ordering rule. None of them explained why the whole thing is split this way. It stayed expensive to read.
2. **`src/session/outgoing-requests.ts:83-121`, `request` / `carry`, together with `src/session/link-life.ts:34-186`.** `LinkLife` exposes six overlapping predicates: `isAttached`, `isUp`, `isStopped`, `retrying`, `awaitsNextLink` and `refusal`. The lanes mix them. `message` checks `refusal() ?? isStopped()`. `receipt` skips both checks up front and only meets `refusal()` inside `budget()`. `link` skips everything. The loop exits on `!attempt.retry || !awaitsNextLink()`. The comment about it spinning helped, but I had to walk the phase transitions by hand to convince myself. The `Lane` doc comment resolved what each lane is for. It did not resolve what each lane actually checks.
3. **`src/messages/expiring-groups.ts:18`, `ExpiringGroups`, with `src/messages/reassembly.ts:110-136, 187-206`, `Reassembler.collect` / `trim`.** The store says outright that it enforces neither its `max` nor its timeout itself. The owner has to check `full`, call `takeExpired()`, and let `weigh()` evict, and `weigh()` can evict the very group being written. In `trim`, `answered = size - 1` for the current key, and the refused segment has already been `set` into the group. That is an invariant spread across two files. The doc comments state the contract honestly, so it was readable, just slow.
4. **`src/wire/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`.** Deciding which field is allowed to set `data_coding` goes through `CodingSource`. An empty Buffer `short_message` counts as source `message_payload`. A string body rewrites `data_coding`, but only when it encodes to something non-empty. Three branches return three different param shapes. The `CodingSource` doc comment is the key, and I only understood it after going back to `message-body.ts`.
5. **`src/messages/dlr-merger.ts:68-184`, `DlrMerger` (`open`, `spend`, `dropOldest`).** It keeps a second `ExpiringGroups<true>` as a tombstone set. `spend()` is called on completion, on expiry, on eviction and on id reuse, and each time it deletes and re-inserts the tombstone. The class doc comment explains why ("merged at most once"). I still had to trace which paths end up in `spend` to be sure a straggler receipt is ignored.
6. **`src/client.ts:219-322`, `bindOn` / `initialAttempts` / `keepTrying`.** For `fromStart` there are two `ReconnectLoop`s: one in the client and one inside each session. `lastErr` lives in a closure, and `settle` is idempotent. `bindOn` removes its abort listener on failure but deliberately keeps it after success, so a later abort closes a bound session. Only README ("That signal also closes the session once bound") told me that was intended rather than a leak.
7. **`src/defs/encodings.ts:483-514`, `messageClassEncoding` / `encodingByDataCoding`.** Bit masks over coding groups I had no background in. There is an orphan `//` comment sitting above a `/** */` doc comment. The GSM 03.38 codec is named `ascii`, and "fall back to ASCII" (line 504) actually means GSM7. That misled me until I checked the `encodings` map.
8. **`src/session/incoming-requests.ts:103-153`, `handle` / `route`.** `arrivedOn` is captured before the application's `onRequest` await and compared afterwards. The `unbind` case closes the session with `AbortSignal.abort()` from inside a handler that the dispatch itself is running. Both have comments, and both still needed a second read to be sure nothing re-enters.
2. **Least want to modify:** the `Session` lifecycle cluster (`session.ts:303-404` plus `LinkLife`). A change to when the link counts as up, stopped or ended touches `LinkLife`, `ReconnectLoop`, `OutgoingRequests.canCarry`, `client.ts` `bindOn`/`bind`, and `IncomingRequests`' `sock` check. Nothing in the types enforces the ordering, so it only lives in the comments.
3. **Expected hard, found easy:** `PduFramer`; the wire types in `defs/types.ts` (long but uniform); TLV read and write, including repeatable tags and keying by id; the reconnect backoff; `bind-direction.ts`; the `Result` convention; `udh.ts` `concatInfo`; `send-sms.ts` validation, which is a flat checklist; `PendingRequests`; `SendWindow`.
4. **Prose debt.**
- **Documentation I needed:**
- README "Reconnect" section, to know the abort listener kept alive in `bindOn` is intended.
- AGENTS "GSM 7-bit is sent unpacked", to trust `segmentUnits` GSM7 153 vs UCS2 134. The one-line comment at `message.ts:13` is close to enough on its own.
- README "Shutdown", to learn that `drain()` returning `{}` when `!canCarry()` also skips waiting on handlers. The code says "nothing is on the wire", which does not mention handlers.
- The charter's decision index points at `docs/decisions.md`, which I was not allowed to open. For several decisions I had only the title and had to take the rest on trust.
- **Where the charter's map is wrong:**
- It says `messages` uses `wire`. In fact `wire/pdu.ts` imports `messages/message.ts` (`decodeMessage`, `encodeBody`).
- `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`.
- `ConcatReference`, a per-session counter, lives in `udh.ts`.
Each of these cost a wrong turn.
- **Prose that told me nothing new:**
- `send()`'s "Sends a request and resolves with the peer's response".
- `LinkTimers`' "Keeps a quiet connection honest".
- `PduFramer`'s "Cuts a byte stream into whole PDUs".
- The charter's test conventions, which are irrelevant to reading `src/`.
- Most of the decision-index lines, which restate README behaviour.
- The 0.4.0 defect table. It is useful history but did not help me read the current code.
- **Duplicated code:** `collectSent` and `collectReceipt` are near-copies, `quoted()` exists twice, and the `emit` / `captureRejectionSymbol` guards are copied between `Session` and `SmppServer`.
5. **Scores.** The problem is intrinsically hard (SMPP session state, reassembly under memory caps, receipt correlation), and that gets no bonus.
- **Navigation 7:** near the "predictable" anchor. The `session/`, `messages/`, `wire/`, `defs/` split and the charter's file map took me from symptom to file first try. It is held below 8 by the misplaced units above and the false import-direction claim.
- **Locality 6:** between the 5 and 7 anchors. Link lifecycle state is split across `LinkLife`, `ReconnectLoop` and `Session`, with ordering rules marked only by comments. `IncomingRequests` reaches back into `session.sock`, `boundAs` and `linkEnd`.
- **Shape 7:** at "predictable". Fan-out per level is small and most names are honest. It is held there by `ascii` for the GSM codec, "fall back to ASCII", `udh.ts` also holding the reference counter, and six overlapping liveness predicates on `LinkLife`.
- **Self-sufficiency 7:** at "predictable". Comments cite SMPP sections and state invariants beside the code. Two behaviours (the kept abort listener, the drain skipping handlers) needed README open beside the code.
- **Overall 6:** a cold senior would be productive within a week and would know to fear the lifecycle cluster. The locality cost there is what holds it below 7.
SCORES nav=7 loc=6 shape=7 self=7 overall=6
## Draft C, architect seat
**Comprehension panel report: Architect, inherited (draft-c, @larvit/smpp)**
I opened no test file. I read every non-test source file under `src/`. I read `defs/types.ts` and `defs/tlvs.ts` by outline plus key sections, and `commands.ts`, `constants.ts` and `errors.ts` only as far as their outline.
## 1. Map from README and file tree only, verbatim
- **Root, the entry points:** `client.ts` has `client()`; `server.ts` has `server()` and `SmppServer`; `session.ts` has `Session`, the orchestrator; `index.ts` is the public surface.
- **Root, cross-cutting:** `result.ts` (Result), `log.ts` (SmppLog), `error-from.ts` (unknown → Error). `defaults.ts` I expect to hold session defaults, and I am unsure how it relates to `session/session-options.ts`.
- **`defs/`:** the SMPP spec tables: commands, TLVs, errors, constants, encodings, wire types.
- **`wire/`:** the codec. `pdu.ts` is pduToObj/objToPdu, `pdu-framer.ts` turns a byte stream into PDUs, `pdu-refusal.ts` is PduRefusedError.
- **`session/`:** what a Session is made of: transport, keepalive timers, reconnect, send window, pending-request correlation, incoming and outgoing requests, bind direction, options. `running-handlers.ts` I guess counts `onSms` promises (README: "1000 handlers", "close() waits for your handlers"). `idle-waiters.ts` and `link-life.ts` are unclear from their names.
- **`messages/`:** message-level logic: encode/split, UDH, concatenation, DLR parse and merge, reassembly, the inbound `Sms` handle, sendSms composition, ids, uuid.
- **Names that do not give their purpose:** `retained-pdu.ts`, `expiring-groups.ts`, `unanswered-error.ts` (why in messages/?), `uuid.ts` (why in messages/?), `pdu-transport.ts` (session, not wire?), `link-life.ts`.
- **README promises I expect to find:** a drain that waits for handlers, then for requests. `smppTime` somewhere in messages. No store (goal 9 says it has not shipped).
## 2. Where the map was wrong, and what each correction cost
| Map claim | Reality | Cost |
|---|---|---|
| `wire/` is the codec | The per-field codec is `defs/types.ts` (684 lines of read/size/write) and `defs/tlvs.ts` (`parseTlvs`/`writeTlvs`). `wire/pdu.ts:10` imports `encodeBody` and `decodeMessage` from `messages/message.ts`, so wire depends on messages. AGENTS.md says the reverse ("`messages` uses `wire`"). | High. I had to reopen `defs/`, and the documented dependency direction is false. |
| `defaults.ts` holds session defaults | It holds every option's default plus `bounds` (limits that are not options). | Low. |
| `session-options.ts` holds option types | It also holds `SessionEvents`, `OnRequest`, `SmsHandler`, and the validation for client and server options (`CheckableOptions` includes `authenticate`, `connectTimeout`, `fromStart`). | Medium. |
| One reconnect concept | `client.ts:278` `keepTrying` runs a second, separate `ReconnectLoop` for `fromStart`. `ReconnectOptions` (session: `connect`/`onConnected`) and the client's `reconnect` (`ReconnectTuning`) are two shapes under one name. | Medium. |
| `running-handlers.ts` counts `onSms` promises | Correct. | None. |
| `messages/` is message logic | It is a 14-file grab-bag with 5 themes: codec helpers, receipts, bounded stores, sending, ids/errors. | Medium. |
## 3. Fan-out, level by level
- **L0, `src/`:** 8 files and 4 directories. It mixes 4 entry points with 4 utilities. Acceptable.
- **L1:**
- `session/`: 12 files. `Session` composes 7 collaborators plus `ConcatReference`.
- `messages/`: 14 files across about 5 themes.
- `wire/`: 3 files.
- `defs/`: 7 files.
- **L2, inside `session.ts`:** about 25 members. The lifecycle cluster alone (`drain`/`stop`/`dropSocket`/`linkLost`/`end`) touches 6 collaborators.
- **Worst level:**
- By count and cohesion, `messages/` (14 files).
- By reading cost, `session/`. `link-life.ts` has 7 near-synonymous predicates, `OutgoingRequests.canCarry` is an 8th, and `ReconnectLoop.isStopped` a 9th.
## 4. Names
**Names that mislead**
- `encodings.ts:45` `ascii` is the GSM 03.38 codec. The comment at `encodings.ts:180` says "alphabets with no codec fall back to ASCII", but the code returns `'GSM7'`.
- `ExpiringGroups` enforces neither the cap nor the expiry; its own doc comment says so at `expiring-groups.ts:18`.
- `defs/` is described as "spec tables" but holds most of the codec.
- `udh.ts` holds the outbound `ConcatReference` counter next to UDH parsing.
- `messages/unanswered-error.ts` is used by `session/outgoing-requests.ts`.
**Concepts with two names**
- `sendSms` and `submitSms` name the same action.
- GSM7 and `ascii` name the same codec.
- "stopped" is held twice: `LinkLife.stopped` and `ReconnectLoop.halted`, both set by `Session.stop()`.
- `smsId`, `message_id` and `base` refer to the same id.
**One name over several concepts**
- `idle`: the `LinkTimers` idle timeout, `IdleWaiters` (a count falling to zero), and `ExpiringGroups.idle()` (stop the sweeper).
- `release`: `SendWindow.release` frees a slot, `RunningHandlers.release` wakes the drain, `LinkLife.release` settles link waiters.
- `settle`: used everywhere.
- `reconnect`: the session's `ReconnectOptions` and the client's `ReconnectTuning`. `checkReconnect` validates only the client shape.
## 5. What I would restructure, ranked
1. **Move the codec into `wire/`:** `defs/types.ts` read/write, `defs/tlvs.ts` parse/write, and `encodeBody`/`decodeMessage`. This makes the documented dependency direction true.
2. **Split `messages/`** into inbound, outbound and receipts. Move `unanswered-error` to `session/`, and move `uuid` out.
3. **Collapse the link predicates** in `LinkLife` into one query per lane (for example `admits(lane)`), absorbing `canCarry` and `ReconnectLoop.halted`.
4. **Split `session-options.ts`:** event and hook types in one place, client/server option checking in another.
5. **Renames:** `ascii` → `gsm7`, `ExpiringGroups` → something that says it only holds keyed deadlines, and distinct names for the `idle`, `release` and `settle` overloads.
**What the structure gets right**
- `Session` is split into collaborators, each with a one-line owner doc.
- `Result` is used uniformly.
- Comments record why at the call site (for example `link-life.ts:173`, `reassembly.ts:162`, `dlr-merger.ts:23`).
- The AGENTS.md file map is accurate at file level.
- Every store is bounded and says so.
## 6. The 3am question
**Time and route, cold:** about 5–10 minutes. README "Shutdown" → `session.ts:267` `close()` → `session.ts:336` `drain()` → `incoming.idle` → `session/running-handlers.ts:54` `RunningHandlers.run` and `:89` `idle`.
**The premise does not match this code.** `sms.sendResp()` does not exist here; a grep for it finds nothing. Every message is answered on arrival (`incoming-requests.ts:233`), and the drain waits for the promise the `onSms` handler returned to settle, not for any answer.
**Likely cause:** a handler whose promise has not settled. The typical case is a handler awaiting `sms.sendDlr()` while the peer never answers the `deliver_sm`: `responseTimeout` (30 s) is longer than `shutdownTimeout` (5 s). The second place to look is the phase after it: `outgoing.idle` → `SendWindow.unfinished()` (`send-window.ts:82`), which counts queued waiters as well as requests on the wire.
**Adjacent hazard (plausible, not confirmed):** `session.ts:340` returns before waiting for handlers when the link cannot carry requests. A `close()` during a reconnect gap therefore skips the handler wait, which README step 2 says always happens. A slow `onRequest` hook is never counted by the drain either.
**Where it rots first:** the lifecycle cluster in `session.ts:336-404`. Its correctness depends on call order: `linkLost` reads `retrying()` before `dropSocket`, and `end` calls `stop` and `dropSocket` before the phase check. Every new link state adds a predicate to `LinkLife`.
**Where the next two features land:**
- Goal 9's store cuts across `DlrMerger`, `Reassembler` and `ExpiringGroups` in `messages/`, and `RunningHandlers` in `session/`. It has no single seam today.
- A per-PDU rate limit (goal 7) becomes a fourth wait in the `OutgoingRequests.carry` loop (`outgoing-requests.ts:100`).
## 7. Hardest places, ranked
1. `src/session.ts:336-404`, `drain`/`stop`/`dropSocket`/`linkLost`/`end`: order dependence, and the early return that skips handlers.
2. `src/session/link-life.ts:50-82`, the `LinkLife` predicates: phase × stopped × reconnects expressed as 7 booleans.
3. `src/session/outgoing-requests.ts:83-121`, `request`/`carry`: 3 lanes and a retry loop whose own comment warns that it spins.
4. `src/session/incoming-requests.ts:103-128`, `IncomingRequests.handle`: holds a Session back-reference, awaits `onRequest`, then re-checks the socket. The session is reachable by two routes: the object and the `sendReceipt` closure.
5. `src/messages/reassembly.ts:110-206`, `Reassembler.collect`/`trim`: eviction by weight, with the `parts - 1` accounting for a segment that was refused.
6. `src/messages/dlr-merger.ts:150-173`, `DlrMerger.open`/`spend`: a second `ExpiringGroups` used as a set of spent ids.
7. `src/wire/pdu.ts:84-136`, `resolveShortMessage`/`resolveBody`: which source's `data_coding` wins.
8. `src/client.ts:250-322`, `keepTrying`/`initialAttempts`: a second reconnect mechanism.
**The unit I would least want to modify:** the `Session` lifecycle cluster, `session.ts:366-404` (`dropSocket`/`linkLost`/`end`).
## 8. Scores
The problem is intrinsically hard: SMPP session lifecycle with reconnect, drain, send window and bounded reassembly. That earns no bonus.
- **Navigation 7.** Sits at "Predictable": the AGENTS.md map and file names took me from symptom to `RunningHandlers` in minutes. It is held below 8 because the codec is split between `defs/` and `wire/`, and `messages/` is a grab-bag.
- **Locality 6.** Between "Honest middle" and "Predictable". The collaborators are real. It is held there by `IncomingRequests` holding a `Session` back-reference, by stopped/up state spread across `LinkLife`, `ReconnectLoop` and `transport.sock.destroyed`, and by the order-dependent `end`/`linkLost`.
- **Shape 6.** Between the anchors. It is held there by the 14-file `messages/`, a `LinkLife` API of 9 predicates, the wire→messages import that contradicts AGENTS.md, and names that lie (`ascii`, `ExpiringGroups`, `idle`/`release` overloads).
- **Self-sufficiency 7.** Sits at "Predictable": the invariants and whys are stated beside the code. It is held below 8 because the drain's early return at `session.ts:340` contradicts the README's shutdown contract, and the file map's layering claim is false.
- **Overall 6.** Capped at 7 by the lowest dimension plus one. It sits at 6 because the hardest code (the lifecycle and the link predicates) is exactly where the order dependence lives.
SCORES nav=7 loc=6 shape=6 self=7 overall=6
## Draft D, junior seat
1. **Hardest places, hardest first**
1. **`src/handled-messages.ts:92` `HandledMessages.offer`, `:125` `run`, and `src/sms.ts:83` `createSms`.**
- `offer` creates an object whose own closure sets `handled.answered`. `sendResp` then takes one of two paths depending on `answeredAs` (`sms.ts:97`).
- After a handler fails, `run` calls `sms.sendResp({ status: retryStatus })` (`:133`). For a multipart message, `answeredOnArrival()` returns an `err` there and nobody reads it.
- I had to trace three files to see that this is intended: the segments were already answered, so there is nothing left to refuse. No comment at `:133` says so.
- Still opaque: what "settle" means here, compared with `IdleWaiters.settle`.
2. **`src/session.ts:239-323`, the shutdown verbs: `unbind`, `close`, `drain`, `finish`, `linkLost`, `dropLink`.**
- There are six near-synonyms, plus `LinkLife.stop`/`drop`/`end` underneath them. `stop()` gets called twice (in `drain` and again in `finish`).
- `unbind`'s return line, `sent.err && !closedOnUnbind ? … : drained`, needed a truth table.
- Re-entrancy: an inbound `unbind` (`incoming-requests.ts:149`) calls `session.close()`, which calls `incoming.drain()` on the same object that is still mid-`route`.
- Doc comments helped with each piece. The overall state machine was never written down in one place.
3. **`src/outgoing-requests.ts:120` `refusal()` and `:74` `request()`, with `src/link-life.ts:31` `LinkLife`.**
- `LinkLife` exposes seven predicates (`isAttached`, `isUp`, `isOver`, `isStopped`, `retrying`, `awaitsNextLink`, `refusal`).
- Callers mix them with `canCarry()` (which also checks `sock.destroyed`). `refusal()` at `:129` reads `isStopped() && canCarry()`, and its comment ("the link's own refusal names the session closed instead") only made sense once I had held all four phases in my head.
- Also surprising: the phase starts at `'up'` before any bind. I had to hunt through `comeBackUp` to see that `'binding'` exists only on reconnect.
4. **`src/reassembly.ts:186` `Reassembler.trim`, with `src/expiring-groups.ts:70` `ExpiringGroups.weigh`.**
- `ExpiringGroups` is a store whose rules its owners enforce: `set()` never evicts, `weigh()` does, `full` is only advisory, and `onSweep` must call `takeExpired()`.
- `trim` reweighs the whole group, may evict the group it is working on, and then computes `answered = parts.size - 1` for that one.
- The `:199` comment explains the `-1`. It took several rereads to see why `open()` evicts by count and `trim` by weight.
5. **`src/pdu.ts:84` `resolveShortMessage` and `:113` `resolveBody`.**
- `CodingSource` decides whether `short_message` or `message_payload` gets to set `data_coding`. An empty-string `short_message` flips it to `'message_payload'`.
- I had to hold four cases at once: Buffer vs string, empty vs not, plus the TLV text. The type comment at `:74` is accurate but dense.
- I had no domain context for why a body could live in two places. The README's "Where the body is" resolved that.
6. **`src/client.ts:253` `initialAttempts`, `:276` `keepTrying`, `:218` `bindOn`.**
- This is a second use of `ReconnectLoop`, separate from the one `Session` owns. It builds a fresh `Session` per attempt, and a `lastErr` closure is shared across attempts.
- `bindOn` comes with an ordering warning: "close() must reach the loop's stop() before its first await". Checking that required reading `Session.close` → `drain` → `reconnectLoop.stop()`.
- It resolved once I saw that `fromStart` is the only path into this code.
7. **`src/dlr-merger.ts:150` `open`, `:166` `spend`.**
- There are two `ExpiringGroups`, and the second one (`spent`) is a tombstone set. `spend` deletes from both, evicts the oldest tombstone, then re-adds.
- The class doc (`:62-67`) explains the "merged once" rule. Without it this would have stayed opaque.
8. **`src/defs/encodings.ts:162` `messageClassEncoding`, `:182` `encodingByDataCoding`, `:45` `ascii`.**
- Bitmask rules come from a spec I have not read. The codec named `ascii` is actually the GSM 03.38 table (`GSM: ascii`), which misled me at first.
- I took the comments on trust; I could not check them.
I opened no tests.
2. **The unit I would least want to modify:** `HandledMessages` together with `createSms` (`handled-messages.ts:92-140`, `sms.ts:83-157`). The "answered" state lives in three places: `answer.done` in the closure, `handled.answered`, and `answeredOnArrival`. They are updated by callbacks across two files. Whether the peer gets exactly one response depends on all three agreeing, and a mistake silently double-answers or never answers a request.
3. **Expected hard, found easy:**
- The wire codec. `defs/types.ts` is long but repetitive, and every read and write checks its range the same way.
- `PduFramer` and `PduTransport`.
- `send-sms.ts`: `checkOptions` is a flat, ordered pipeline.
- `sms-id.ts`, `concat.ts`, `udh.ts`: small files whose names tell the truth.
- The "nothing throws" rule makes every call site look the same, so I stopped needing to think about control flow.
4. **Prose debt.**
- **Needed:**
- I needed domain background: what a DLR is, `esm_class`, `data_coding`, UDH vs `sar_*`, and why `data_sm` changes meaning with direction. None of it is in `src/`.
- I found it in README.md sections "Receiving in depth", "Server in depth" and "Delivery receipts". That cost reading about 780 lines to extract about 60 useful ones.
- Comments cite SMPP section numbers (e.g. "5.3.2.26", "4.6.2") that a junior cannot resolve without the spec.
- The AGENTS.md architecture list was the most valuable single piece: one line per file, and accurate.
- **Told me nothing:**
- Comments that restate the code: `Session.send` "Sends a request and resolves with the peer's response.", `client()` "Connects to an SMSC and binds.", `LinkTimers.clear` context, and `bindCarries`'s doc, which mostly repeats its three lines.
- The long AGENTS.md "Conventions" paragraph on test fixtures (irrelevant to reading `src/`).
- The defects table: it is history, not an explanation of the current code, though it did hint at domain pitfalls.
5. **Scores** (the problem's own difficulty is high: a stateful protocol with reconnect, drain and reassembly, and it gets no bonus here):
- **Navigation 7.** Predictable: the AGENTS.md file map plus descriptive file names got me to the right file first try for almost every question. What holds it below 8: one symptom such as "why was this refused with ESME_RTHROTTLED" is spread across `incoming-requests.ts` (`retryStatus`, `refusedSegmentStatus`), `handled-messages.ts` (`refuses`) and `reassembly.ts` (`Refusal`).
- **Locality 5.** Honest middle: liveness state in `LinkLife` is read through seven predicates from three classes. `generation()` is captured in closures (`incoming-requests.ts:106`, `:253`), `answered` is mutated through a callback, and ordering constraints are documented only in comments (`client.ts:239`, `session.ts:349`).
- **Shape 6.** Between 5 and 7: classes are small and fan-out is bounded, but some names mislead. The GSM codec is called `ascii`, and six-plus near-synonymous teardown verbs (`stop`/`end`/`drop`/`finish`/`linkLost`/`dropLink`/`clear`) mark distinctions I had to work out myself.
- **Self-sufficiency 5.** Honest middle: the code comments give terse, accurate reasons, but the domain model a newcomer needs to read them lives only in README.md and the SMPP spec, so I kept the README open the whole time.
- **Overall 5.** Capped at 6 by locality; I land at 5 because both locality and self-sufficiency cost me rereads on the stateful session core. The codec and message layers alone would sit near 7.
SCORES nav=7 loc=5 shape=6 self=5 overall=5
## Draft D, mid seat
1. **Hardest places, ranked hardest first**
1. **`src/session.ts:265-362`: `Session.drain` / `finish` / `linkLost` / `dropLink` / `comeBackUp`, read together with `src/link-life.ts:31-135` (`LinkLife`).**
- One question, "can a request go out right now?", depends on four pieces of state: `LinkLife.phase` (binding/down/ended/up), `LinkLife.stopped`, `ReconnectLoop.halted`, and `OutgoingRequests.canCarry()`. The last one is `link.isUp() && !sock.destroyed`.
- `drain()` calls `link.stop()`, then branches on `canCarry()`. `finish()` calls `stop()` again, then `dropLink()`, then `end()`.
- `comeBackUp` uses `!this.link.retrying()` to mean "close() landed during the rebind". Here `retrying()` is being used as a stand-in for "not stopped", which the name hides.
- I had to trace every caller by hand to be sure `close` fires exactly once and `disconnected` is never followed by `close` on the same drop. The per-method doc comments helped. Nothing ties the whole state machine together in one place; this stayed the most expensive read.
2. **`src/outgoing-requests.ts:262-351`: `OutgoingRequests.request` / `refusal` / `attempt`.**
- A `for(;;)` loop holds one link-wait budget (`link.hold()` returns a closure) plus a window slot, and retries only when `retryOnNextLink && awaitsNextLink()`.
- `refusal` line 317 (`pastDrain !== true && isStopped() && canCarry()`) is a three-way condition. Its comment explains why the *other* branch exists, not this one.
- `attempt` registers `pending.wait` before `transport.write`, and checks abort twice (in `refusal` and again in `attempt`). The comment at line 325 resolved the second check.
- The `pastDrain` flag reaches here from `IncomingRequests` through `Session.incomingFor`. It is action at a distance.
3. **`src/pdu.ts:84-136`: `resolveShortMessage` / `resolveBody` (plus `readParams` at 249).**
- The `CodingSource` return value decides whether an encoded `message_payload` may overwrite `data_coding`. Without the domain, I had to derive why an empty `short_message` hands `data_coding` to the TLV. The `CodingSource` doc comment half-resolves it.
- `readParams` passes `paramNumber(params.sm_length, 0)` as the length to *every* field's `read`. It only works because `sm_length` precedes `short_message` in wire order. That is an order dependence stated only as the general "parameter order is wire order" warning, not at the call.
4. **`src/handled-messages.ts:359-423` and `src/expiring-groups.ts:442-554`: `HandledMessages.offer` / `run` / `refuses`, and `ExpiringGroups`.**
- `ExpiringGroups`'s contract is inverted: it "enforces neither max nor timeout itself", only `weigh()` evicts, `onSweep` must call `takeExpired()`, and `takeOldest` goes through `delete` (which stops the timer) while `takeExpired` goes through `remove`. Each owner (Reassembler, DlrMerger, HandledMessages) re-implements the policy.
- In `HandledMessages`, the `answered` flag is set by a closure threaded into `createSms`. `run` uses an identity check (`running.get(key) !== handled`) to detect that `clear`/`sweep` got there first. `refuses()` has hysteresis state (`atBound`) and calls `sweep()` as a side effect.
- The class doc comment resolved the intent. The mechanics took rereads.
5. **`src/incoming-requests.ts:105-260` together with `src/server.ts:542-567`: `IncomingRequests.handle` / `route` / `onMessage` / `offer`, and `handleRequest`.**
- Searching for where a bind is accepted, I found `IncomingRequests.unhandled` answering binds with `ESME_RALYBND`. The real bind handling is in server.ts, injected as `onRequest`, so the "application hook" slot is also the server's own bind handler.
- The charter's Decisions index says this ("composes the application's onRequest after its own bind handling"), but the reader gets there only after a wrong turn.
- Link generation is checked twice by different mechanisms: inline in `handle`, and as a `lostLink` closure in `offer`.
- `carriedAs`/`standsInFor` rewrites `data_sm` depending on `linkEnd`, a mutable public field set after construction (`session.linkEnd = 'smsc'` in server.ts:586).
6. **`src/reassembly.ts:425-444`: `Reassembler.trim`.**
- `weigh()` returns evicted groups, possibly including the current one. `answered = parts.size - 1` excludes the refused segment from the loss count.
- `collect` only weighs incomplete groups; the completing segment is never weighed. I had to confirm that is intended.
- The comments at 361 and 437 resolved it, after two reads.
7. **`src/dlr.ts:342-424`: `messageType` / `receiptStatus` / `dlrFromPdu`.**
- Four message types, with `'unmarked'` meaning "maybe a receipt if the body parses to both an id and a state". Status comes from TLV, then body, then UNKNOWN, and `statusId` and `statusMsg` can disagree by design.
- This is domain-heavy but well commented with spec sections. README's "Delivery receipts" section closed the gap.
8. **`src/defs/encodings.ts:302-341`: `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.**
- Bit-twiddling over GSM 03.38 coding groups that I had no context for. The comments give the bit positions, so it resolves with care.
- The GSM codec object is named `ascii` (line 196), which is a lying name for GSM 03.38.
2. **The unit I would least want to modify: the Session link lifecycle (`Session.drain` / `finish` / `linkLost` / `comeBackUp`) together with `LinkLife`.**
- Correctness depends on call order across three classes. `stop()` must reach the loop before the first await; client.ts:239 says so from *another file*.
- `close` and `disconnected` must stay exclusive, and `end()` must release waiters exactly once.
- Any change risks a hung `close()` or a double `close` event, and nothing local tells you which invariant you just broke.
3. **Expected hard, found easy.**
- The wire codec in `defs/types.ts`: repetitive, every read is range-checked, and `Result` is uniform.
- `PduFramer`: short, with the quadratic concern stated.
- The `send-sms.ts` check pipeline: linear and flat, each refusal named.
- `DlrMerger` severity ranking: one comment explains why wire values can't be compared.
- `bind-direction.ts`: `standsInFor`/`bindCarries` are tiny and explained.
- The UDH walk in `udh.ts`.
- The no-throw discipline made every call site read the same way.
4. **Prose debt.**
- **Needed, and where I found it:**
- What ESME and SMSC are. Only inferable from `LinkEnd`'s comment and README.
- What "answered on arrival" means. README "Server in depth", roughly a 400-line scroll.
- Why `data_sm` flips direction. The comment in bind-direction.ts is sufficient.
- How the server's bind handling composes with `onRequest`. Only in the AGENTS.md Decisions index, as one line whose reasoning is in docs/decisions.md, which I was barred from.
- The 134/153 segment budget. Covered both inline (message.ts:364) and in AGENTS, so it was cheap.
- Many AGENTS decision lines are pointers into a file I couldn't open. For the lifecycle ("A deliberate shutdown drains; an unusable link and an abort do not"), the one-liner was the only statement of the rule the code implements.
- The AGENTS architecture map was accurate and was the cheapest, most useful prose.
- **Told me nothing the code didn't already say:**
- `Session.send`'s "Sends a request and resolves with the peer's response."
- README's "Everything exported" table, which duplicates `index.ts`.
- The `defaults.ts` preamble.
- AGENTS "Conventions", about 30 lines on test fixtures and teardown, which are irrelevant to reading `src/`.
- The repeated "Injected so expiry can be exercised without a wall clock" on four options types.
- **Minor drift:** README types `onSms` as `(sms) => Promise<void> | void`; the code declares `(sms) => unknown`.
5. **Scores.** Intrinsic difficulty is high: a stateful protocol session with reconnect, drain, windowing and reassembly. It gets no bonus.
- **Navigation 8.** Above 7 "the layout answers where does this live": `src/` is flat, file names match contents, and the AGENTS map is accurate. It stops short of 9 because server bind handling lives behind the `onRequest` slot, which cost one wrong turn.
- **Locality 6.** Between 5 and 7: most units stand alone. Link liveness is split across `LinkLife.phase`, `stopped`, `ReconnectLoop.halted`, `canCarry()`'s socket check and generation counters. Changing shutdown means holding session.ts, link-life.ts, outgoing-requests.ts and client.ts at once, and `linkEnd` is mutated after construction.
- **Shape 7.** At "predictable": classes are small and fan-out is bounded per level. A few names lie: `ascii` for the GSM codec, `HandledMessages` for messages still being handled, `retrying()` used as "not closed", and `settle` meaning different things in five classes.
- **Self-sufficiency 7.** At 7: comments carry the why with spec section references at the hard points (receipts, UDH, data_coding bits). What is missing is the lifecycle invariant and the bind composition, which exist only as index lines pointing at a decisions file.
- **Overall 6.** Capped at loc+1 = 7. I place it at 6 because the hardest part, the session lifecycle, is hard both because the problem is hard and because its state is spread across files. It is neither localized nor marked as one place.
SCORES nav=8 loc=6 shape=7 self=7 overall=6
## Draft D, senior seat
1. **Hardest places, hardest first**
1. **The link lifecycle across four owners.** `src/session.ts:265-362` (`drain`, `finish`, `linkLost`, `dropLink`, `comeBackUp`), `src/link-life.ts:31` (`LinkLife`), `src/reconnect-loop.ts` (`stop`/`halted`) and `src/outgoing-requests.ts:120` (`refusal`, which uses `canCarry()` = `link.isUp() && !sock.destroyed`).
- There are three "stopped" notions: `LinkLife.stopped`, `ReconnectLoop.halted`, and `phase === 'ended'`, which also sets `stopped`. `drain()` and `finish()` each call `link.stop()` and `reconnectLoop?.stop()`, so I had to hold the order of the calls to see which one decides the `close` event versus `disconnected`.
- `link-life.ts:38` starts `phase` at `'up'`, although `'binding'` exists. The first link therefore reports `isUp()` before any bind, and the charter's "a bind is what makes it one" holds only for reconnected links. I worked this out myself; nothing in the code says so. The code mostly explains itself, but that one point stayed opaque.
2. **Who answers a failed handler.** `src/handled-messages.ts:92-140` (`offer`, `run`), together with `src/sms.ts:83-157` (`createSms`, `sendResp`, `answeredOnArrival`) and `src/incoming-requests.ts:252` (`offer`).
- An `answered` flag is set through a callback that `offer` builds around a `handled` const, which that same closure refers to. `SmsRoute = Omit<SmsHandlers,'answered'>` and a mutable `Answer` object add further state, and the `lostLink` generation closure is built in yet another class.
- When a multipart handler fails, `run()` calls `sendResp({status: retryStatus})`. That returns an `err` from `answeredOnArrival`, and the `err` is discarded. That is how "its answer stands" comes out right, and nothing says so. README's "A handler that fails" bullet resolved it.
3. **Reassembly eviction.** `src/reassembly.ts:110` (`collect`) and `:187` (`trim`), with `src/expiring-groups.ts:70` (`weigh`).
- `weigh()` can evict the group that is being weighed, so `trim` counts it as `parts.size - 1` answered. The `full` / `unplaceable` refusals map to three statuses in `refusedSegmentStatus`.
- `ExpiringGroups` enforces its limits unevenly: only `weigh` evicts, while `max` and `timeout` fall to the owners, and I had to find that in its class docstring. The inline comments resolved it, but it took a reread.
4. **Building and reading the message body.** `src/pdu.ts:84-136` (`resolveShortMessage`, `resolveBody`) and `:249` (`readParams`).
- On the write side, `CodingSource` decides which of `short_message` and `message_payload` may overwrite `data_coding`. An empty buffer counts as `'message_payload'`. I had to hold four branches at once.
- On the read side, `readParams` passes `sm_length` as the `length` argument to every wire type's `read`. The same parameter means the TLV length in `defs/types.ts`. Only the `commands.ts` comment on wire order hints at this coupling.
5. **data_coding bit logic.** `src/defs/encodings.ts:159-190` (`messageClassEncoding`, `encodingByDataCoding`).
- It is dense bit-twiddling with no context, and a `//` comment sits above a separate `/** */` block, so I could not tell which of the two it belonged to.
- Some names are wrong. `'LATIN1'` is returned for 8-bit binary. The GSM codec is named `ascii` (`:45`).
- The docblocks resolved most of it. The class-group comment stayed only half clear.
6. **`DlrMerger`'s two stores.** `src/dlr-merger.ts:68`, with `spend` at `:166` and `open` at `:150`. Two `ExpiringGroups` (`groups` and `spent`) are both mutated by `spend()`. `open()` checks both, and `dropOldest` spends. The class docstring explained the "merged at most once" rule. I still had to trace the steps by hand.
7. **Retrying the first bind.** `src/client.ts:218-320` (`bindOn`, `initialAttempts`, `keepTrying`). This is a second `ReconnectLoop` outside `Session`, with a fresh session per attempt and a `lastErr` closure. Correctness depends on the order in which abort listeners are added and removed, and on the comment "close() must reach the loop's stop() before its first await". The comments resolved it.
2. **The unit I would least want to modify:** `LinkLife` (`src/link-life.ts`). `Session`, `OutgoingRequests` (`isUp`, `awaitsNextLink`, `refusal`, `hold`) and `IncomingRequests` (`generation`) all read its phase. Its `stopped` flag duplicates the reconnect loop's, and its initial `'up'` is an unstated exception to its own `binding` rule. A change there reaches the drain, the queued sends and response correlation, and no single file shows all of that.
3. **Expected to be hard, found easy:**
- The codec: `defs/types.ts` is long but uniform, and every read is range-checked the same way.
- `PduFramer`, `ReconnectLoop` and `PendingRequests`.
- The typing of `TlvInputs` and `Tlvs`.
- `splitMessage` and the budget per segment.
- Navigation overall: the charter's one-line-per-file map matched the tree exactly.
4. **Prose debt**
- **Needed, and what it cost to find:**
- README "Receiving in depth" and "Shutdown", to learn the half-bound hysteresis, the five-minute handler cutoff, and what happens when a handler fails after answering. Finding them was cheap, but they sit in a user document, not beside `HandledMessages`.
- The rationale for the link-life decisions. AGENTS.md only indexes it ("One owner decides whether a link can carry a request…") and I was not allowed to open `docs/decisions.md`, so the initial-`'up'` question stayed open.
- I opened no tests.
- **Told me nothing the code did not already say:**
- `session.ts:203` "Sends a request and resolves with the peer's response".
- The getter docstrings on `boundAs` and `peerInterfaceVersion`.
- `UnansweredError`'s docstring, which restates its message.
- `retryStatus`'s docstring.
- The idle-timeout rationale, written twice (`defaults.ts:13` and `client.ts:193`).
- `checkSessionOptions`'s docstring describes one case, `maxOutstanding: 0`, not the function, which misleads slightly.
- AGENTS.md's 0.4.0 defect table and its long test-fixture paragraph cost reading time and did not help with `src/`.
- **Small duplication noticed:** `collectSent` in `send-sms.ts` and `collectReceipt` in `sms.ts`, and `quoted()` in both `session-options.ts` and `bind-direction.ts`.
5. **Scores**
| Dimension | Score | Anchor and cause |
| --- | --- | --- |
| Navigation | 8 | Between 7 and 9. The Architecture map and truthful file names took me from symptom to file first try every time. The lifecycle behaviour spread over `Session`, `LinkLife` and `ReconnectLoop` is what keeps it from 9. |
| Locality | 6 | Between 5 and 7. Most collaborators are standalone, with injected `now` and dependencies. But whether a link can carry a request, is stopped, or has ended is split across `LinkLife`, `ReconnectLoop`, `OutgoingRequests.canCarry` and order-dependent calls in `Session`, and the handled-message `answered` state runs through closures in three files. |
| Shape | 7 | Predictable. Fan-out is bounded per level and nearly every name tells the truth. The exceptions are `ascii` for GSM, `LATIN1` standing for binary, and `isUp()` being true before the first bind. |
| Self-sufficiency | 7 | Predictable. Inline comments carry most of the "why" (SMPP section references, peer quirks). The handler bound and failure semantics needed README, and the reasoning behind the link-life decisions sits in a document I could not open. |
| Overall | 7 | Predictable, within the cap of lowest dimension plus one. The hard corners are few and I know which to fear, but the lifecycle corner is spread across four files instead of sitting in one marked place. |
The problem is intrinsically hard: SMPP session semantics, reconnecting with no resends, a draining shutdown, and reassembly under memory bounds. The scores give no bonus for that.
SCORES nav=8 loc=6 shape=7 self=7 overall=7
## Draft D, architect seat
**Comprehension panel report: Architect, inherited (draft-d)**
**Order note:** I read AGENTS.md right after README and the tree, before I had written the map down. Its architecture listing matched the map below and changed nothing in it. I opened no test files.
### 1. Map (README + tree only, verbatim)
Top-level areas I expected in `src/`:
- **A. Entry points:** `index.ts` for the public surface, `client.ts` for connect and bind with reconnect, `server.ts` for the listener, auth and close.
- **B. Session core:** `session.ts` as the hub. `session-options.ts` and `defaults.ts` for options. `bind-direction.ts` for which commands a bind type carries.
- **C. Link lifecycle:** `link-life.ts` (up, down or ended?), `link-timers.ts` (enquire_link and idle), `reconnect-loop.ts` (backoff), `pdu-transport.ts` (socket to PDUs).
- **D. Outbound:** `send-sms.ts` (split and submit), `outgoing-requests.ts` (the request path), `pending-requests.ts` (seqNr correlation), `send-window.ts` (maxOutstanding), `unanswered-error.ts`.
- **E. Inbound:** `incoming-requests.ts` (dispatch), `sms.ts` (the onSms handle), `handled-messages.ts` (probably the "held while the handler runs" bound), `reassembly.ts`, `concat.ts`, `udh.ts`, `message-body.ts`.
- **F. Receipts:** `dlr.ts` (parse), `dlr-merger.ts` (messageDlr), `sms-id.ts` (hex/decimal and `<base>-<n>`).
- **G. Codec:** `pdu.ts`, `pdu-framer.ts`, `pdu-refusal.ts` (PduRefusedError), `retained-pdu.ts` (?), `defs/*` (spec tables).
- **H. Text:** `message.ts` (encode, split, smppTime) and `defs/encodings.ts`.
- **I. Utilities:** `result.ts`, `error-from.ts`, `log.ts`, `uuid.ts`, `idle-waiters.ts` (?), `expiring-groups.ts` (?).
Names that did not give their purpose:
- `idle-waiters`: idle peer or idle count?
- `retained-pdu`
- `expiring-groups`: groups of what?
- `handled-messages`: reads as "already handled".
- `error-from`
- `defaults` vs `session-options`
README features I could not place, or that were missing:
- Goal 9's store is absent, as the README says.
- smppTime and smppDate: presumably `message.ts`.
- At the repo root, `MIGRATION-NOTES.md` and `DESIGN.md` beside `MIGRATION.md` and `docs/decisions.md`: I cannot tell their purpose apart from the others.
**Where the map was wrong, and what each correction cost:**
- **`handled-messages.ts` (medium cost, and it is the crux of the 3am question).** I guessed "held until `sendResp()`". It actually holds a message until the **handler's promise settles**. `answered` is recorded only to decide the retry refusal.
- **`link-life.ts` (medium).** It is not only a state flag. It is also the queue where requests wait for the next link, with two orthogonal state variables, `phase` and `stopped`.
- **`expiring-groups.ts` (medium).** It is a shared TTL store whose contract is "enforces neither max nor timeout itself" (`expiring-groups.ts:18`). Each of its three owners re-implements the policy differently. `HandledMessages` uses it for running handlers, which are not groups. `DlrMerger` uses a second instance as a "spent" set.
- **Cheap corrections:**
- `session-options.ts` also holds `SessionEvents` and all option validation.
- `bind-direction.ts` also holds bind-record validation (`checkedBind`) and `undeclaredInterfaceVersion`.
- `reassembly.ts` holds `decodeSegments`, which `sms.ts` uses.
- `idle-waiters.ts` is "wait until a count reaches 0".
- `retained-pdu.ts` copies PDUs off the wire and weighs them.
### 2. Fan-out by level
- **L0, repo root:** 22 entries, 8 of them prose documents. Two documents I could not tell apart by name.
- **L1, `src/`: 36 files plus `defs/`, all flat. This is the worst level.** Only names and the AGENTS listing group them into the 9 areas above. Nine is bounded; 37 is not, and the directory gives no help.
- **L2, `defs/`:** 7 files, bounded and clear.
- **L3, units:**
- `Session` wires 9 collaborators and has about 15 methods.
- `IncomingRequests` owns 3 things and reaches back into `Session`.
- `OutgoingRequests` owns 3.
- `LinkLife` has 12 methods over 2 state variables. By method count it is the densest unit.
### 3. Names
**One name over several concepts:**
- **idle** means four things:
- `IdleWaiters`: a count falls to 0.
- The `LinkTimers` idle timeout: a silent peer.
- `ExpiringGroups.idle()`: stop the sweeper.
- `SendWindow.idle()`: the drain.
- **refusal** means four things:
- `pdu-refusal`: an unreadable PDU.
- `LinkLife.refusal()`: the session is over.
- `OutgoingRequests.refusal()`: a request cannot go out.
- The reassembly `Refusal`: `'full' | 'unplaceable'`.
- **settle** covers waiters, pending requests, `IdleWaiters.settle` and `HandledMessages.settle()`, which means "wake the drain if empty".
- **Shutdown verbs:** `stop`, `end`, `finish`, `drop`, `dropLink`, `linkLost`, `halted`, `isOver`, `isStopped`. `link.stop()` refuses new work while `reconnectLoop.stop()` halts timers: same verb, different meanings.
**One concept with several names:**
- "Answered": `Answer.done` (`sms.ts:81`), `Handled.answered` (`handled-messages.ts`), `answeredAs` and `answeredOnArrival`.
- Writing a response: `answer()`, `sendReturn()`, `sendResp()`.
- The reassembly octet cap: option `maxOctets` vs `defaults.maxReassemblyOctets`. The option name does not say "reassembly", yet a sibling cap exists (`maxHandledOctets`).
- The server's idle timeout: the literal `defaults.idleTimeout` 40 000 vs the client's derived `2 × enquireLinkInterval`.
- Two date formatters, `smppDate` and `smppTime.encode`, in `message.ts`, with duplicated pad chains.
**Misleading:**
- `HandledMessages` means "being handled". The README calls them "messages being handled".
- `ExpiringGroups` holds running handlers, which are not groups.
- `bind-direction.ts` holds more than direction.
**Copies:**
- `quoted()` appears twice (`session-options.ts:78`, `bind-direction.ts:54`).
- `collectSent` (`send-sms.ts:274`) and `collectReceipt` (`sms.ts:183`) are near-twins.
- An inline `thrown instanceof Error ? … : new Error(String(thrown))` appears three times (`client.ts:89`, `reconnect-loop.ts:80`, `reconnect-loop.ts:136`) instead of `errorFrom()`.
### 4. What I would restructure, ranked
1. **Group `src/` into about 6 folders:** link, outbound, inbound, receipts, codec, text. The AGENTS listing already draws those lines, so this only moves the map from a document into the layout.
2. **Give the shutdown/link vocabulary one owner.**
- Collapse `LinkLife.phase` and `stopped` into one state enum.
- Rename so that "stop" means one thing everywhere.
- Move `OutgoingRequests.refusal`'s `pastDrain && isStopped && canCarry` condition (`outgoing-requests.ts:129`) behind one `LinkLife` predicate.
3. **Make `ExpiringGroups` enforce its own policy,** or split it into a TTL store and a set. Today three owners re-implement "full", weight and sweep, and its sweeper interval equals its timeout. So expiry is lazy by up to 2× (`expiring-groups.ts:60`): the README's "five minutes" handler cap is really 5–10 minutes when no traffic arrives. That is a plausible claim drift; I derived it from the code and have not verified it.
4. **Merge the "answered" state into one place,** so `sms.ts` and `HandledMessages` stop tracking the same fact.
5. **Use `errorFrom()` everywhere.**
- `reconnect-loop.ts:80` runs `String(thrown)` inside the `.catch` that is meant to contain an application throw. `errorFrom`'s own comment says `String()` can throw.
- If it does, `void this.run()` rejects unhandled and `attempting` stays `true`, which wedges the loop. The trigger is a null-prototype object thrown from an application-supplied `ReconnectOptions.connect` or `onConnected`, reachable because `Session` is publicly constructible.
- I call this plausible, not verified.
**What the structure gets right:**
- Files are small (all under 420 lines).
- Every file name maps to one noun that also appears in `Session`'s fields.
- `Session` reads as a table of contents.
- Imports point one way.
- Hard-rule-1 result types are uniform.
- Comments carry the WHY at the line: spec section numbers, peer quirks, past defects.
- `defs/` is clean.
- `PduFramer`, `PendingRequests`, `SendWindow` and `ReconnectLoop` each fit in the head alone.
### 5. The 3am question
**Symptom:** during a graceful shutdown the session hangs until `shutdownTimeout`, although the application called `sendResp()` on every message.
**Path, cold, about 2–3 minutes:** `Session.close` → `drain` (`session.ts:265`) → `this.incoming.drain(...)` (`session.ts:274`) → `IncomingRequests.drain` (`incoming-requests.ts:163`) → `HandledMessages.idle` (`handled-messages.ts:111`).
**The unit is `HandledMessages.run` (`handled-messages.ts:125`).** The entry is deleted at `:138` only after `await this.handle()` returns. `sendResp()` only flips `handled.answered`, and the drain never reads it.
**So the peer's handler has not returned.** The likely cause is that it is awaiting `sms.sendDlr()`. That call waits for every `deliver_sm_resp`, up to `responseTimeout`, which defaults to 30 s, longer than the 5 s shutdown. It can also wait without bound behind a full send window, because `sendDlr` passes no signal.
This is designed behaviour: the `OnSms` type doc, README lines 108 and 389, and the AGENTS decision all say it. The fix is on the caller's side (return the handler, or fire-and-forget the receipt). The code states this at the type (`session-options.ts:39`), so no document is needed.
**Where it rots first:**
- The link/shutdown triangle: `Session.drain`/`finish`/`linkLost`/`dropLink`/`comeBackUp` plus `LinkLife` plus `OutgoingRequests.refusal`. Three units read `LinkLife` state. Correctness depends on call order: `link.stop()` before `canCarry()`, `drop()` before `end()`. `IncomingRequests` also snapshots `link.generation()`.
- Next, `ExpiringGroups` and its three divergent owners.
**Where the next two features land:**
- **Goal 9's store** would have to thread an interface through `Session` → `IncomingRequests` → `Reassembler`/`HandledMessages`/`DlrMerger`, which is every `ExpiringGroups` owner. That is the costliest seam in the code base.
- **A per-PDU rate-limit hook (goal 7)** lands cleanly in `OutgoingRequests.request` beside `SendWindow.acquire`.
### 6. Hardest places, ranked
1. `session.ts:265-362`: `drain`, `finish`, `linkLost`, `dropLink`, `comeBackUp`. Order-dependent shutdown across 4 collaborators.
2. `link-life.ts:31` `LinkLife` as a whole: `phase` × `stopped`, and 7 predicates with overlapping meanings.
3. `outgoing-requests.ts:74-134`, `request` and `refusal`: the link-wait/window retry loop and the `pastDrain` exemption.
4. `handled-messages.ts:67-138`, `refuses`/`offer`/`run`: hysteresis, a hidden sweeper timer, and the answered flag written from `sms.ts`.
5. `expiring-groups.ts` `weigh` together with `reassembly.ts:187` `trim`: eviction can take the current key.
6. `pdu.ts:84-136`, `resolveShortMessage` and `resolveBody`: the `CodingSource` rules.
7. `incoming-requests.ts:218-260`, `onMessage` and `offer`: the bound check, reassembly, answer on arrival, and generation capture.
8. `dlr.ts:216` `dlrFromPdu`: the `messageType`/`receiptStatus` precedence.
**The unit I would least want to modify is `LinkLife`.** Everything that decides whether a request may go out reads it, and its meaning is spread over 7 predicates.
**Intrinsic difficulty:** high. It is an SMPP session layer with reconnect, a send window, reassembly, receipt merging and drain semantics, and it earns no bonus for that.
### 7. Scores
- **Navigation 7:** "Predictable". The file names plus `Session`'s field list got me from symptom to unit in 3 hops. The flat 37-file `src/` and the misleading name `HandledMessages` keep it below 9.
- **Locality 6:** between "honest middle" and "predictable". `LinkLife` state is read by `Session`, `OutgoingRequests` and `IncomingRequests`, and shutdown correctness depends on call order. `ExpiringGroups` pushes its own policy onto three owners.
- **Shape 6:** between "honest middle" and "predictable". Units are small and mostly named truthfully. But L1 has 37 ungrouped entries, and "idle", "refusal", "settle" and "stop" are each overloaded.
- **Self-sufficiency 7:** "Predictable". Comments at each unit state its invariants and the spec section behind them, and the 3am answer is readable at the `OnSms` type with no document open. A reader still needs the AGENTS listing to see the area grouping that the layout does not show.
- **Overall 6:** capped by locality and shape at 6. A cold senior is productive within a week on everything except the link/shutdown triangle.
SCORES nav=7 loc=6 shape=6 self=7 overall=6
@@ -1,710 +0,0 @@
# Round 3: drafts E and F
## Draft E, junior seat
1. **Hardest places, hardest first**
1. **`src/session-life.ts:175` `SessionLife.attempt()`, with `enter()` at `:115` and `linkLost()` at `:109`.** The async reconnect continuation re-enters the state machine after two awaits. It snapshots `this.links` and later compares it, and it reads state through `is()` only to stop TypeScript narrowing. The step to `bound` is not in this file: it goes `rebind` → `client.ts` `bind()` → `Session.bound()` → `life.bound()`, three files away. There is also hidden re-entrancy. `effects.linkDown()` calls `sock.destroy()`, which fires the transport's `onClose` → `life.linkLost()`. That call is harmless only because `attached()` is already false. The ASCII diagram at `:8-24` and the comment "a transition from any other state is ignored" resolved most of it. Why `links` could change during `rebind` stayed opaque.
2. **`src/outgoing-requests.ts:97` `request()`, `sendOnce()` at `:116` and `attempt()` at `:162`.** There is a `for(;;)` retry around three nested waits: link, window slot, response. Each has its own deadline or timeout semantics, and `written` plus `state() !== 'down'` decide whether to loop. The `misuse()` check runs here and again in `Session.send()` (`session.ts:180`), and the reason for the duplicate is a riddle comment ("named as one ahead of the drain"). The JSDoc on `request()` and the `UnansweredError` naming resolved the intent. I had to read `README` "Sends and the link" to trust it.
3. **`src/handled-messages.ts:61` `refuses()` and `:120` `run()`.** `refuses()` looks like a predicate but sweeps, logs, and flips `atBound` hysteresis. `run()` answers the peer after the handler, and the answer depends on whether `sms.answered` was flipped by a closure inside `sms.ts`. It then removes the entry only if `running.get(key) === sms`, because a sweep may already have dropped it while the handler keeps running. `ExpiringGroups` gets `max` here but, by its own doc, does not enforce it, so I had to go and read `expiring-groups.ts` to know who does.
4. **`src/reassembly.ts:186` `trim()`, with `ExpiringGroups.weigh()` at `src/expiring-groups.ts:70`.** `weigh()` evicts the oldest groups and may return the current key itself. Then `answered = parts.size - 1` subtracts the just-arrived segment, because that one gets a `full` refusal and the peer keeps it. Holding "set never evicts, weigh does, owners check full" across two files cost two rereads. The inline comments resolved it.
5. **`src/sms.ts:82` `createSms()` and `:126` `sendResp()`.** A mutable `answer` record is captured in a closure and exposed through getters. `link` is the socket at arrival, compared by `destroyed` rather than against `session.sock`. `answeredAs` means "multipart, already answered on arrival". I only understood why `sendResp()` on a multipart message is a no-op after reading README "Server in depth" (answered on arrival). The code alone did not tell me.
6. **`src/pdu.ts:84` `resolveShortMessage()` and `:113` `resolveBody()`.** The `CodingSource` idea is hard for someone without SMPP: which of `short_message` and `message_payload` gets to set `data_coding`, and when an empty buffer counts as "payload". Also, `readOptionalParams()` at `:267` retries parsing with one skipped NULL. The comments are accurate but assume the domain. README "Building" and the SMPP terms table were needed.
7. **`src/defs/encodings.ts:152-191` `messageClassOf()`, `messageClassEncoding()`, `encodingByDataCoding()`.** Bit masks (`0x80`, `0x10`, `0xF0`, `>> 2 & 0x03`) against GSM 03.38 coding groups I have never seen. The comments cite spec sections I cannot open. This stayed opaque. I trust it only because the tests presumably pin it; I did not open them.
8. **`src/client.ts:240` `retryUntilBound()` and `:278` `keepTrying()`.** This is a second backoff loop, separate from `SessionLife`'s. Its `settle` callback is called on every failure and does not settle anything; it only records `lastErr`. The name misled me until I read the callback body at `:290`. AGENTS' architecture line ("the first-connect retry of reconnect.fromStart") told me why it exists.
2. **The unit I would least want to modify:** `SessionLife.enter()` / `attempt()` (`src/session-life.ts:115-203`). Every lifecycle effect fans out from it through `LifeEffects` closures defined in `session.ts:260`. Those closures call back into the transport, whose socket events call `life.linkLost()` again. Correctness rests on "ignored from any other state" and on the ordering of effects before emit. I could not predict what a new transition would re-trigger without running it.
3. **Expected hard, found easy:**
- `PduFramer`: small, one job, and the quadratic-avoidance comment explains the only trick.
- The wire-type table in `defs/commands.ts`, where the wire-order warning sits right at the table.
- The result pattern and "nothing throws": consistent everywhere, so no surprises.
- `defaults.ts`: one place, grouped.
- `sms-id.ts`, `concat.ts`, `message-body.ts`, `log.ts`, `pdu-refusal.ts`: each read cold in one pass.
- `send-sms.ts`: long but linear, a chain of `check*` functions.
- The AGENTS architecture map, which got me to the right file first time for every question I had.
4. **Prose debt**
- **Documentation I needed:**
- README "SMPP terms" table: essential and cheap to find, linked from the table of contents.
- README "Server in depth" and "Receiving in depth", for answered-on-arrival and the handled-message bound. The rules behind `sms.ts` and `handled-messages.ts` live there, not in the code.
- AGENTS architecture list: essential for navigation.
- The AGENTS decisions index lines are cryptic without `docs/decisions.md`, which I was not allowed to open (for example "A report is final unless its `esm_class` or its state says otherwise").
- Spec knowledge (`esm_class` bits, `data_coding` groups) is cited by section number and never explained. That cost the most and was never repaid.
- **Prose that told me nothing the code did not already say:**
- The duplicated deliver JSDoc in `outgoing-requests.ts:84` and `pending-requests.ts:57`.
- `/** A socket is on the link. */` on `attached()`.
- The AGENTS "Defects found in 0.4.0" table, which is history and not a guide to the current code.
- The AGENTS test conventions, which are irrelevant to reading `src/`.
- A different cost: many comments are compressed to riddles and needed several reads each. Examples are "Announced when the wait is over rather than when it starts: a cancelled one never happened." (`session-life.ts:160`) and "A misuse is named as one ahead of the drain, rather than blamed on the shutdown."
5. **Scores**
- **Navigation 7:** at the "Predictable" anchor. The AGENTS file map and honest file names (`pdu-framer`, `link-timers`, `sms-id`) took me symptom → file first try. It stops short of 9 because reconnect lives in two places (`session-life.ts` and the separate `client.ts` retry loop).
- **Locality 5:** at the "Honest middle" anchor. Collaborators are wired by closures back into `Session` (`LifeEffects`, `PduTransport` callbacks, `IncomingRequests` calling `session.emit`, `.sock` and `.close`). The `Sms` answer record is mutated from two modules. Safe changes in `SessionLife` and `HandledMessages` need the whole session held in your head.
- **Shape 6:** between 5 and 7. Fan-out per level is bounded and the files are small. Names overload or lie, though:
- `settle` means four different things (`IdleWaiters`, `PendingRequests`, `SendWindow`, the `client.ts` callback that settles nothing).
- `handlers` in `IncomingRequests` means `{answer, send}`, not `onSms`.
- `refuses()` and `closing()` read as predicates but mutate.
- `SessionLife.carries()` is dead: unused in `src/`, duplicated by the switch in `OutgoingRequests.waitForLink()`.
- **Self-sufficiency 5:** at the "Honest middle" anchor. The generic parts (framer, codec types, results, timers) stand alone. The session semantics (answered-on-arrival, the handled-message bound) and all the `data_coding`/`esm_class` bit logic needed README sections or the spec open beside the code.
- **Overall 5:** as someone new to the domain, I would take an area in a day or two, with rereads and some wrong turns. The problem's intrinsic difficulty is genuinely high (a protocol state machine, reassembly, and receipt correlation over a flaky link), and it gets no bonus here.
SCORES nav=7 loc=5 shape=6 self=5 overall=5
## Draft E, mid seat
1. **Hardest places, hardest first**
1. **The answer to an inbound message**, `incoming-requests.ts:218` (`IncomingRequests.onMessage`), `handled-messages.ts:120` (`HandledMessages.run`) and `sms.ts:126` (`sendResp`), with `sms.ts:82` (`createSms`) holding the `Answer` record.
- Whether the peer has been answered, and under which id, is decided in three places:
- `onMessage` answers multipart segments one by one on arrival and passes `answeredAs`.
- `createSms` turns `answeredAs` into a pre-set `status: 'ESME_ROK'`.
- `run` answers after the handler only when `!sms.answered`, choosing the retry status if the handler failed.
- "Which link" is carried as a `Socket` identity that is compared through `.destroyed` in two places (`IncomingRequests.handle` and `sendResp`).
- To reason about "handler throws after a multipart message" I had to keep all three files in my head. The `Sms` type docs and the README section "Server in depth" resolved it. It is followable but not local.
2. **`ExpiringGroups` and the classes built on it**, `expiring-groups.ts:19`, with `reassembly.ts:187` (`Reassembler.trim`) and `dlr-merger.ts:105/150/166` (`collect`, `open`, `spend`).
- The store refuses to enforce its own `max` and `timeout` ("owners check full and call takeExpired(); only weigh() evicts"). So each of the three owners re-implements the capping itself (`open` → `dropOldest`, `sweep` before `collect`).
- `weigh()` can evict the very key being weighed. `trim` then does `answered = parts.size - 1` for that case, and I needed two reads to see why.
- `DlrMerger` runs a second `ExpiringGroups<true>` (`spent`) as a tombstone set, with `spend()` writing to both stores.
- The doc comments stop you misusing the store, but understanding any one owner means holding the store's partial contract.
3. **`resolveShortMessage` / `resolveBody`**, `pdu.ts:84` and `pdu.ts:113`.
- `CodingSource` decides whether `short_message` or `message_payload` may set `data_coding`. That depends on Buffer vs string vs empty, and on whether the command's table has a `short_message` at all.
- I had to hold four or five branches at once, and `data_coding` gets rewritten in two different spots.
- The type comment on `CodingSource` helped. The rule behind it ("a string body is written in the alphabet its own data_coding names") is only a title in the AGENTS index.
4. **The retry loop in `OutgoingRequests.request` / `sendOnce`**, `outgoing-requests.ts:97` and `:116`.
- `sendOnce` returns `undefined` to mean "loop again". Whether to loop depends on `attempt.written` and on a live read of `state() !== 'down'` after two awaits.
- With `LinkWaiters`, `SendWindow` and `PendingRequests` underneath, a send passes through four waits with three different abort and timeout rules.
- The doc comments on `request` and `Attempt.written` resolved it, as did the README bullets under "Sends and the link".
5. **`SessionLife.enter` / `attempt`**, `session-life.ts:115` and `:175`.
- The ASCII state diagram is very good. What cost me was `attempt()`: it re-checks `is('down')` after `connect`, then `this.links !== link || !is('connected')` after `rebind`.
- `rebind` calls `Session.bound()`, which calls `life.bound()` → `enter('bound')`. That is a re-entrant path back into the machine from inside an await.
- The comment "the one continuation that re-enters the machine" marks it, and the diagram resolved it.
6. **`Session.unbind` / `drain`**, `session.ts:218` and `:317`.
- `life.closing()` is a query-named method that performs the transition.
- `closedOnUnbind = wasOpen && !this.life.attached()` infers that the peer dropped the link in answer to our unbind.
- The return value orders two errors with different priorities.
- The doc comment helped. The mutating `closing()` still surprised me.
7. **The two reconnect loops**, `client.ts:240` (`retryUntilBound`) and `client.ts:278` (`keepTrying`).
- AGENTS says "the reconnect loop is the `down` state", but `fromStart` is a second, hand-rolled backoff loop in `client.ts`. The charter's file list does mention it.
- The callback named `settle` is called on every failed attempt and does not settle: it only records `lastErr`. That name misled me until I read the body of `keepTrying`.
8. **The overloaded third argument of `WireType.read`**, `pdu.ts:249` (`readParams` passes `sm_length` to every param reader) and `defs/types.ts:280` (`tlvInt`).
- For a mandatory parameter it is `sm_length`; for a TLV it is the TLV header length.
- Only `buffer` and the tlv variants use it, and nothing names the dual meaning. I found it by grepping callers. It stayed half-opaque until then.
2. **The unit I would least want to modify:** `Reassembler.collect` + `trim` together with `ExpiringGroups.weigh`.
- Weight accounting is spread across `set` (zeroes the weight), `weigh` (evicts, possibly the caller's own key) and `trim` (recomputes the group total from scratch).
- Every refusal path decides a peer-visible status, and a lost group is reported to the application as traffic gone. An off-by-one there is silent data loss.
3. **Expected hard, found easy:**
- The codec tables (`defs/commands.ts`, `defs/tlvs.ts`) and the TLV typing, including `tlvSpecs` keying each definition to its own name.
- `PduFramer` and `PduTransport`.
- The DLR parsing in `dlr.ts`: every regex and every fallback has a one-line reason.
- `SessionLife` itself, thanks to the diagram.
- GSM packing (153 vs 134). The `segmentUnits` comment, plus the AGENTS section "GSM 7-bit is sent unpacked", made it obvious even to someone who knows nothing about SMPP.
4. **Prose debt**
- **What I needed and what it cost:**
- The README "SMPP terms" table (ESME/SMSC, `esm_class`, UDH, `sar_*`). Cheap to find, essential without domain knowledge.
- The README sections "Sends and the link", "Receiving in depth" and "Server in depth", to confirm the intent behind items 1 and 4. Each took a scroll-and-search.
- The AGENTS architecture map, for navigation.
- Several `// SMPP 3.4 x.y.z` comments point at a spec I have never read, and I had to take them on trust. Examples: `respIdParams`, `refusalAnswer`, `messageClassOf`.
- The AGENTS decision index gives titles only. Twice (the drain ordering, and `data_coding` ownership in the codec) the title told me a rule existed without telling me the rule, and the reasoning lives in `docs/decisions.md`, which I was told not to open.
- I opened no tests.
- **Prose that told me nothing the code did not:**
- For reading `src/`: the AGENTS test-conventions bullets (fixtures, `resume()`, `t.after` ordering) and the "Defects found in 0.4.0" table, which is history about another codebase.
- The README Goals and Audience.
- A few restating doc comments: `PendingRequests.deliver` ("False means nobody was"), `LinkTimers.clear`'s neighbours, `defs/index.ts`'s grouping.
- The duplicate `emit` / `captureRejectionSymbol` guard comments in `session.ts` and `server.ts`.
5. **Scores.** Intrinsic difficulty is moderate to high (wire protocol, reconnect, backpressure, multipart), and it gets no bonus below.
- **Navigation 8.** Between 7 and 9: the AGENTS file map plus one concept per file (`dlr-merger.ts`, `link-waiters.ts`, `pdu-refusal.ts`) got me from a symptom to the right file cold every time. The one detour was the second reconnect loop living in `client.ts`.
- **Locality 6.** Between 5 and 7: `SessionLife` does centralise the state, but the answered-or-not state spans `IncomingRequests`, `HandledMessages` and `Sms`. `ExpiringGroups` also pushes enforcement of its own invariants onto three owners, and link identity is a shared `Socket` reference compared across modules.
- **Shape 7.** Predictable: fan-out is bounded (`Session` wires about six collaborators through narrow option objects) and most names tell the truth. It is held there by a few that lie or hide effects: `closing()` mutates, the `settle` callback in `keepTrying` does not settle, and `IncomingRequests.clear()` also empties the handled messages.
- **Self-sufficiency 7.** Predictable: nearly every non-obvious line carries a one-line why, often with a spec reference, and the hard corners are marked. It stays below 9 because several rules (codec `data_coding` ownership, drain ordering) exist in the code as outcomes whose reasons are only indexed titles, and the domain vocabulary needs the README glossary open.
- **Overall 7.** Capped at 7 by Locality 6. A cold mid-level reader knows within a day which corners to fear (items 1, 2 and 3 above). They are few and marked, but item 1 is harder than the problem requires.
SCORES nav=8 loc=6 shape=7 self=7 overall=7
## Draft E, senior seat
**Comprehension panel report: senior maintainability seat, `@larvit/smpp` (draft-e), whole project**
I read `README.md`, `AGENTS.md` and every file under `src/`, including `defs/`. I opened no tests.
## 1. Hardest places, hardest first
1. **`src/reassembly.ts:187` `Reassembler.trim()`, and `src/expiring-groups.ts:70` `ExpiringGroups.weigh()`**
- `weigh()` can evict the group that is being weighed. `trim()` then has to work out whether that group survived. It also counts only `parts.size - 1` segments as lost when that group is the victim, because the new segment "stays with the peer".
- To get this right I had to hold four things at once: `weigh`'s eviction order, `takeOldest → delete → idle`, the "answered" arithmetic, and the caller in `collect()`, which answers `full`.
- The inline comments made it clear in the end, after two passes.
2. **`src/expiring-groups.ts:19` `ExpiringGroups`, as a contract**
- The class docstring says it "enforces neither max nor timeout itself", so every owner has to call `full` and `takeExpired()` in the right order.
- `HandledMessages` stretches this across two classes. `IncomingRequests.onMessage` (`incoming-requests.ts:218`) calls `handled.refuses()` before reassembly, and `offer()` comes later, when the message is whole.
- Nothing states that the cap holds only because `refuses()` ran first. I had to reconstruct that myself, and it stayed implicit.
3. **`src/session-life.ts:115` `SessionLife.enter()` / `:175` `attempt()`, with `src/session.ts:260` `lifeFor()`**
- The ASCII transition table in the header is the best piece of prose in the repo.
- Two things cost me:
- The header says "a transition from any other state is ignored", but `enter()` has no guard. The guards live in the public wrappers (`bound()`, `closing()`, `linkLost()`, `end()`), so I had to check each one.
- The effects are closures defined in `Session`. To follow `down → connected → bound` I had to jump between `session-life.ts`, `session.ts` `lifeFor`/`linkDown`, `OutgoingRequests.linkUp`/`linkLost` and `PduTransport.attach`.
- `attempt()`'s re-check after the await (`this.links !== link || !this.is('connected')`) is commented and fine.
4. **`src/client.ts:240` `retryUntilBound()` / `:278` `keepTrying()`**
- This is a second backoff loop, separate from `SessionLife`'s. The charter and README say `fromStart` goes "through that same loop", but it doesn't: it's a separate loop that uses the same `Backoff` class.
- The callback type is named `Settle`, but on an error it doesn't settle anything; it only records `lastErr`. You learn that from a comment inside the lambda in `keepTrying`.
- Add the `waiting()` closure and an abort listener, and this is four nested pieces of control flow for one feature. It resolved in the end, but the name misled me along the way.
5. **`src/pdu.ts:84` `resolveShortMessage()` / `:113` `resolveBody()`**
- The `CodingSource` rule decides which of `short_message` and `message_payload` may rewrite `data_coding`. It depends on whether the command's table has a `short_message` at all, on Buffer vs string, and on zero length.
- The comment on the `CodingSource` type states the rule, but I had to trace three return shapes to confirm it.
- Next to it, `readParams()` (`:240`) passes `sm_length` as the length argument to every param read. That works only because `sm_length` comes earlier in wire order. `commands.ts` documents wire order, but not that this depends on it.
6. **`src/sms.ts:126` `sendResp()`, with `src/session.ts:304` `answer()`**
- `sendResp` checks the socket the message arrived on, `link.destroyed`. `answer()` then writes to `transport.sock`, the current socket.
- This is correct only because a socket is replaced only after it has been destroyed (`PduTransport.attach`).
- `IncomingRequests.handle()` (`:104`) relies on the same equivalence. It is not stated anywhere I read.
7. **`src/handled-messages.ts:120` `HandledMessages.run()`**
- Covers expiry, a handler failure, answering after the handler settles, and the identity check `running.get(key) !== sms`, which catches a `clear()` or sweep that ran in the meantime.
- The class docstring covers it. It is compact but dense.
8. **`src/defs/encodings.ts:163` `messageClassEncoding()` / `encodingByDataCoding()`**
- Bit-twiddling over GSM 03.38 coding groups that I had no background for.
- The comments are adequate. The problem is inherently hard; it is not badly written. Stacking a `//` comment on top of a `/** */` comment made it unclear which one belonged to which function.
## 2. The unit I would least want to modify
`Reassembler.trim()` together with `ExpiringGroups.weigh()`.
- The eviction, the "is the current group gone" question, the lost-segment count and the ESME-facing status (`full` → throttled) are all spread over two files, and each file assumes the other's behaviour.
- A wrong change here silently loses traffic the peer will never resend, which is the README's worst outcome.
- Nothing in the code would stop me. Only a test would catch the mistake.
## 3. Expected hard, found easy
- **Framing (`pdu-framer.ts`):** short, and the quadratic-join rationale is right there.
- **The send path:** `OutgoingRequests.request/sendOnce/attempt`. The `written` flag makes "resend or not" a single boolean.
- **`PendingRequests` and `SendWindow`**
- **The TLV table's self-keyed generic**
- **Receipt parsing (`dlr.ts`)**
- **`splitMessage`:** the budget comment together with the charter's "GSM 7-bit is sent unpacked" section settled the 153/134 question at once.
## 4. Prose debt
**Needed:**
- The `session-life.ts` state table: essential, and cheap to find.
- `AGENTS.md`'s file map: accurate, and the fastest route from a symptom to a file.
- The charter's "GSM 7-bit is sent unpacked" section.
- README "Server in depth", to understand why segments are answered on arrival.
**Missing:**
- The invariants in items 2 and 6. They are written down nowhere I was allowed to read.
- The decisions index points at `docs/decisions.md`, which I couldn't open. Twice (the `fromStart` "same loop" wording, and the abort-dance duplication) the one-line index entry made a claim the code did not obviously bear out, and there was nothing local to check it against.
**Prose that told me nothing new:**
- The `/** Injected so expiry can be exercised without a wall clock. */` comment, repeated on four option types.
- `// Called unbound, so the application's hook never sees this class as its this` (`incoming-requests.ts`).
- Most of the charter's test-convention paragraph (fixtures, `recordingDeps`), which is irrelevant for reading `src/`.
- Much of the defect table, as far as reading `src/` goes.
## 5. Scores
- **Navigation 7:** Predictable. The `AGENTS.md` file map is accurate line by line, and file names match their content (`link-waiters`, `pdu-refusal`, `sms-id`), so I landed first try on every symptom I tried. It doesn't reach 9 because `defaults` are applied in three different layers (`Session`, `IncomingRequests`, `Reassembler`), so finding "where does this default apply" takes a search.
- **Locality 6:** Between honest middle and predictable. It is held down by three unwritten cross-class invariants: owners enforce `ExpiringGroups`' limits, `refuses()` must run before `offer()`, and a destroyed arrival socket stands in for the current socket. `SessionLife`'s effects are also closures back into `Session`.
- **Shape 7:** Predictable. Every file is small, fan-out per level is bounded, and names mostly tell the truth. It is kept from 8 by a few names that mislead:
- `closing()` is a transition, not a predicate.
- `Settle` doesn't settle on an error.
- `bound()` exists on two layers with different contracts.
- `onRequest` names both the server's wrapper and the application's hook.
- **Self-sufficiency 7:** Predictable. The why-comments sit on the lines that need them, and spec section numbers are cited. It is held back by a charter index that points to a decisions file whose claims I could not check locally.
- **Overall 6:** The capped maximum is 7 (lowest dimension plus one). I gave 6 because the costs are concentrated in exactly the bounded-store and link-identity code where a mistake loses traffic.
The problem itself is moderately hard: flow control across two peers, the SMPP body and encoding rules, and reconnect races. That earns no bonus.
SCORES nav=7 loc=6 shape=7 self=7 overall=6
## Draft E, architect seat
# Comprehension panel, seat "Architect, inherited": @larvit/smpp (draft-e)
I read README.md, AGENTS.md and every non-test file in src/. I opened no test, ran nothing, edited nothing and ignored the pre-loaded AGENTS.md.
## 1. Map from README.md and `ls -R src test` only (verbatim)
> Flat `src/` of 36 files plus `defs/` (7). I expect seven areas that the layout does not show:
> A. **Spec tables**, `defs/`: commands, constants, encodings, errors, TLVs, wire types, plus an index grouping them.
> B. **Codec**: `pdu.ts` (pduToObj/objToPdu), `pdu-framer.ts` (stream to PDUs), `pdu-refusal.ts` (PduRefusedError), `retained-pdu.ts` (?), `result.ts`, `error-from.ts` (?).
> C. **Message content**: `message.ts` (encode/split/bitCount, and probably smppTime), `message-body.ts`, `concat.ts`, `udh.ts`, `reassembly.ts`, `expiring-groups.ts` (a TTL map, probably under reassembly).
> D. **Receipts**: `dlr.ts` (parse *and* build receipts), `dlr-merger.ts` (messageDlr), `sms-id.ts` (hex/decimal ids).
> E. **Session core**: `session.ts`, `session-life.ts` (?), `session-options.ts` (types), `defaults.ts`, `link-timers.ts` (enquire_link/idle), `backoff.ts` (reconnect loop?), `pdu-transport.ts`, `link-waiters.ts` (?), `idle-waiters.ts` (?).
> F. **Request flow**: `outgoing-requests.ts`, `pending-requests.ts` (seq/correlation), `send-window.ts`, `unanswered-error.ts`, `send-sms.ts`, `incoming-requests.ts`, `sms.ts` (the onSms handle), `handled-messages.ts` (messages whose onSms runs, for the bound and the drain).
> G. **Entry points**: `client.ts`, `server.ts`, `index.ts`, `log.ts`, `uuid.ts`.
> Names that do not give their purpose: session-life, link-waiters vs idle-waiters, retained-pdu, error-from, defaults vs session-options, and backoff (a loop or only a delay?).
> Expected from the README but missing: the goal-9 store (the README says it has not shipped). At the repo root, MIGRATION-NOTES.md and DESIGN.md sit beside the six documents the charter names, with no stated purpose.
### Corrections, cheapest first
| Map guess | Reality | Cost |
|---|---|---|
| backoff.ts is the reconnect loop | Delay arithmetic only. The loop is the `down` state of `SessionLife`. | Low. The AGENTS table says so. |
| link-waiters / idle-waiters | Requests waiting for a bound link / a count falling to zero | Low |
| dlr.ts parses and builds receipts | Building lives in `sms.ts` (`receiptText`, `sendDlr`, `collectReceipt`) | Medium. I went to dlr.ts first, then grepped for `stat:`. |
| session-options.ts is option types | Also holds `SessionEvents`, bind-direction logic (`bindCarries`, `standsInFor`, `bindCommands`) and all option validation (`checkSessionOptions`) | Medium. The name hides three concerns. |
| Whole-message decoding lives in message.ts | `decodeSegments` is in `reassembly.ts:63` and is called from `sms.ts` | Low-medium |
| One reconnect loop | Two. `SessionLife.attempt/schedule`, plus a second in `client.ts:240` `retryUntilBound`/`keepTrying` for `fromStart`. Both log `'reconnect - retrying'`. | Medium. The same log line from two places is a 3am trap. |
| retained-pdu, error-from | Heap-detach and weight of a held PDU; turning a thrown value into an Error | Low. The AGENTS table covers both. |
## 2. Fan-out by level
- **Repo root:** about 8 prose documents (README, AGENTS, CHANGELOG, MIGRATION, MIGRATION-NOTES, DESIGN, todo, CLAUDE) plus 5 directories. MIGRATION-NOTES and DESIGN are not in the charter's "each file answers one question" list.
- **`src/`, the worst level:** 37 entries. They fall into 7 real areas, but only the AGENTS.md table shows that. Without the table this level is past any bound you can hold at once.
- **`src/defs/`:** 7. Good.
- **Within the session:**
- `Session` holds 8 collaborators.
- `OutgoingRequests` holds 3 (PendingRequests, LinkWaiters, SendWindow).
- `IncomingRequests` holds 2 (HandledMessages, Reassembler) plus 6 injected fields.
- `HandledMessages` holds 2 (ExpiringGroups, IdleWaiters).
- Depth is at most 4 and fan-out at most 8 per level. Bounded.
- **Files:** most are under 250 lines. `defs/types.ts` (684 lines) is long but uniform: one wire type after another.
## 3. Names
**Misleading:**
- `SessionLife.closing()` (`session-life.ts:97`) reads like a predicate but performs the transition and returns whether it did. `carries()` and `attached()` next to it really are predicates.
- `session-options.ts` holds events, bind direction and validation as well as options.
- The comment on `SessionOptions.shutdownTimeout` (`session-options.ts:117`) says "How long a drain waits for the requests already on the wire". It also bounds the wait on running handlers (`session.ts:324`). That comment is false.
- In `client.ts:234,288`, `Settle` is called once per failed attempt as well as on success, so it does not settle anything.
- `maxOctets` (a public option) bounds reassembly only. The handled-message octet cap is a separate constant, `maxHandledOctets`.
**One name over several concepts:**
- **`idle`** has four meanings:
- A count reaching zero: `IdleWaiters`, `SendWindow.idle`, `HandledMessages.idle`.
- Stopping the sweep timer: `ExpiringGroups.idle()`.
- The idle-timeout timer: `LinkTimers.idle`.
- The `idleTimeout` option.
- **`settle`** has four meanings: wake all waiters (`IdleWaiters.settle`), wake only if the count is zero (`HandledMessages.settle`), resolve one request (`PendingRequests.settle`), and the local `settle` closures in LinkWaiters, SendWindow and client.
- **`link`** means the socket (`SmsInput.link`, `const link = this.session.sock`), the connection's lifetime (`LinkState`, `LinkTimers`, `LinkWaiters`) and which end of the connection this is (`LinkEnd`).
**Two names for one concept:**
- The lost link is spelled `linkLost` (the SessionLife transition and `OutgoingRequests.linkLost`) and `linkDown` (the LifeEffects hook and `Session.linkDown`).
- The end of the session is spelled `end()`, the state `ended`, and the effect `over`.
- A message whose handler is running is spelled "handled" (the class and its logs), `running` (its field) and "being handled" (README).
## 4. What I would restructure, ranked
1. **Group `src/` into about 6 directories:** `codec/` (pdu, framer, refusal, retained-pdu, defs), `message/` (message, message-body, concat, udh, reassembly), `receipts/` (dlr, dlr-merger, sms-id, and receipt building moved out of sms.ts), `session/` (session, session-life, link-timers, pdu-transport, backoff), `requests/` (outgoing/pending/send-window/link-waiters/idle-waiters, incoming/handled-messages/sms), and top level (client, server, index). Today the AGENTS table does the job the directory tree should do.
2. **One retry loop.** Have `fromStart` reuse the `SessionLife` loop, or give client.ts's loop a distinct name and log line.
3. **Split `session-options.ts`** into `bind-direction.ts` and `option-checks.ts`, and move `SessionEvents` into `session.ts`.
4. **Retire the overloaded verbs** (`idle`, `settle`, `linkLost`/`linkDown`) and rename `closing()` to something like `beginDrain()`.
5. **Deduplicate the collectors.** `collectReceipt` (`sms.ts:182`) is `collectSent` (`send-sms.ts:274`) without ids.
**What the structure gets right:**
- `SessionLife` is one state value with the transition diagram in its own header (`session-life.ts:7-24`). Effects reach the rest of the session only through `LifeEffects`.
- `OutgoingRequests` reads state through a function and never copies it.
- Every collaborator takes a narrow options object.
- Result types are used throughout, so control flow has no second error channel.
- The comments are dense and mostly say why, not what.
- The AGENTS table matches the code file for file.
## 5. The 3am question
**Symptom:** during a graceful shutdown the session hangs until the shutdown timeout, even though the application already called `sms.sendResp()` on every message.
**Cold path, about 3 to 5 minutes:**
1. `Session.close` (`session.ts:235`) calls `drain` (`session.ts:317`).
2. That calls `this.incoming.drain(timeout)` (`incoming-requests.ts:163`).
3. That calls `HandledMessages.idle` (`handled-messages.ts:106`).
4. The unit is **`HandledMessages.run`** (`handled-messages.ts:120`). A message leaves `running`, which is what releases the drain, only after `onSms`'s promise settles. `sendResp()` records the answer and releases nothing.
**Likely cause:** the handler is still awaiting something, typically `sms.sendDlr()`. That goes through `handlers.send`, which is `outgoing.request`: it bypasses the closing-state refusal and waits up to `responseTimeout` (30 s), which is longer than `shutdownTimeout` (5 s).
**What gets you there:** the README (lines 107-109) and the `OnSms` doc (`session-options.ts:87-92`) both say the handler's promise is the hold. **What slows you down:** the `Sms.sendResp` doc (`sms.ts:47-51`) says nothing about the hold, and the `shutdownTimeout` comment wrongly claims it only bounds requests.
**Where it rots first:** the wiring in `Session`'s constructor and `transportFor`/`lifeFor`/`incomingFor` (`session.ts:105-294`).
- Eight collaborators are cross-wired through closures that capture `this.life` and `this.transport` before those are assigned. That is safe today only because of construction order.
- `IncomingRequests` reaches back into `Session` for `sock`, `close()`, `emit`, `bindAllows`, `boundAs` and `linkEnd`, so the dependency runs in both directions.
- Every lifecycle feature touches three files: session-life (transition), session.ts (effect) and the collaborator.
**Where the next two features land:**
1. **The goal-9 store** lands under `ExpiringGroups`, which has three owners: `DlrMerger`, `Reassembler` and `HandledMessages`. Each wraps it differently (weigh-evicts, a "spent" set, sweep callbacks), so a persistent seam would have to be cut three times or `ExpiringGroups` would have to become the store interface.
2. **A per-PDU rate-limit hook** lands in `OutgoingRequests.sendOnce` (`outgoing-requests.ts:116`), between the window acquire and `attempt`, and must respect the rule that a written request is never retried. The code states that rule, so the change is contained. An alphabet hook would be far worse: `EncodingName` is a closed union that ripples through defs/encodings, message.ts and send-sms.ts.
## 6. Hardest places, ranked
1. `session.ts:105-294`, `Session` constructor plus `transportFor`/`lifeFor`/`incomingFor`: closure wiring, and initialisation order matters.
2. `session-life.ts:175`, `SessionLife.attempt`: re-enters after two awaits and guards with the `links` counter plus `is('connected')`. It is correct, but you have to hold the whole diagram to read it.
3. `session.ts:218`, `Session.unbind`: the `wasOpen`/`closedOnUnbind` arithmetic and the order of error precedence.
4. `outgoing-requests.ts:97-183`, `request`/`sendOnce`/`attempt`: a `for(;;)` retry keyed on `written` and a fresh `state()` read.
5. `handled-messages.ts:62,120`, `refuses()` (a query that mutates the hysteresis flag and sweeps) and `run()` (answer after settle, then conditional delete).
6. `pdu.ts:84-136`, `resolveShortMessage`/`resolveBody`: which field's encoding sets `data_coding`.
7. `reassembly.ts:187` `trim` with `expiring-groups.ts:70` `weigh`: eviction can take the current key, with off-by-one accounting of lost parts.
8. `client.ts:240-309`, `retryUntilBound`/`keepTrying`: the second loop and its misnamed settle callback.
**The unit I would least want to modify** is `SessionLife.enter` (`session-life.ts:115`) together with its effect bindings in `Session.lifeFor` (`session.ts:260`). One transition's meaning is split across two files and five callbacks.
**Intrinsic difficulty is high**, and none of the scores below credit it: an async protocol with reconnect, drain, correlation, reassembly and a byte-exact codec against peers that do not follow the spec.
## 7. Scores
- **Navigation 7:** at the "Predictable" anchor. The AGENTS file table plus accurate file names got me from the 3am symptom to `HandledMessages.run` in minutes. It sits no higher because the 37-file flat `src/` depends on that table, and two parallel retry loops share one log line.
- **Locality 6:** between "Honest middle" and "Predictable". `LifeEffects` and the `state()` accessor are real seams. Holding it down: `IncomingRequests` reaches back into `Session`, the constructor wiring depends on order, and one lifecycle change spans session-life, session and a collaborator.
- **Shape 6:** between 5 and 7. Nesting below the top level is bounded (8 at most per level). Holding it down: the 37-entry top level, a misnamed `session-options.ts`, a mutating `closing()`, and `idle`/`settle`/`link` each covering several concepts.
- **Self-sufficiency 7:** at "Predictable". The state diagram, invariant comments and "why" comments sit at the code. Holding it back from higher: the false `shutdownTimeout` comment (`session-options.ts:117`), and `Sms.sendResp` not saying it does not release the drain.
- **Overall 6:** capped at 7 by the lowest dimension plus one. The hard parts are marked but spread across the session wiring, and the top level needs a document to map it.
SCORES nav=7 loc=6 shape=6 self=7 overall=6
## Draft F, junior seat
**Junior A: comprehension report for draft-f**
I read README.md, AGENTS.md and every file under `src/`, all in draft-f. I opened no test.
## 1. Hardest places, hardest first
1. **`src/expiring-groups.ts:38-54, 111`: the `ExpiringGroups` getters `full`, `size` and `weight`, and `get()`, which all call `expire()`.**
- Reading a property has side effects. It can fire `onDrop`.
- In `Reassembler`, `onDrop` becomes `onLost`, then `port.report`, then a `sessionError` emit. In `RunningHandlers` it logs a warn and settles the drain's waiters.
- So `this.running.size` in `IncomingRequests.refusedAtBound` (`incoming-requests.ts:224`) can emit events on the application's emitter. I only found this by following `onDrop` through three classes.
- No comment at the call sites says so. It stayed a trap even after I understood it.
2. **`src/reassembly.ts:113` (`Reassembler.collect`) with `weighed()` at `:180`, `dropped()` at `:194` and the `weighing` field at `:91`.**
- `weighing` is a side channel. It is set around a `weigh()` call so that the drop callback, which fires synchronously inside it, can subtract the newest segment from the reported loss.
- `weighed()` then reads `get(key)` again to find out whether its own group was evicted.
- To follow it I had to hold several things at once:
- part and total validation;
- a group that is new or already there;
- eviction by count inside `set`;
- eviction by weight inside `weigh`;
- the "newest segment stays with the peer" rule.
- The field's comment explains why but not how. It was resolved only after a second read.
3. **`src/incoming-requests.ts:260` (`onMessage`) and `:292` (`handOver`), together with `sms.ts:126` (`sendResp`) and `:196` (`sendDlr`).**
- A message's answer is spread over four places:
- answered on arrival for segments;
- the handler's return;
- an early `sendResp()`;
- the refusal on a throw, which is itself split by `answeredOnArrival`.
- The shared state is the mutable `answer` object captured in the closures of `createSms` (`sms.ts:80`).
- `throttledStatus` versus `refusedSegmentStatus` needs the `carriedAs` / `standsInFor` indirection for `data_sm`.
- The README's "Receiving in depth" section resolved it. The code alone did not.
4. **`src/pdu.ts:84` (`resolveShortMessage`) and `:113` (`resolveBody`).**
- `CodingSource` decides which of `short_message` and `message_payload` may rewrite `data_coding`.
- There are five return paths, depending on Buffer or string, empty or not, and a string `message_payload`.
- `data_coding` is patched onto the params from two places.
- The type comment at `:74` helps, but I had to trace each branch by hand. It remained partly opaque, for example why an empty encoded string falls to `message_payload`.
5. **`src/defs/encodings.ts:152-191`: `messageClassOf`, `messageClassEncoding`, `encodingByDataCoding`.**
- Bit masks (`& 0x80`, `>> 2 & 0x03`, `0xF0`) that I had no background for.
- Two comments are stacked oddly at `:160-162`: a `//` block and then a `/** */`, both describing the function below.
- It stayed opaque without the GSM 03.38 spec. The intrinsic difficulty is high.
6. **`src/defs/tlvs.ts:110` (`WriteValue`) and `:284` (`keyedTlvs`, with the `isTlvs` guard).**
- Conditional types nested three deep, plus a runtime re-validation of what the code just built, because casts are banned.
- Hard rule 4 in AGENTS.md explains why the guard exists. The type gymnastics remained costly.
7. **`src/reconnect-loop.ts:82` (`schedule`), `:127` (`attempt`) and `:64` (`adopt`).**
- `upAt` resets the backoff only after a link lasted `maxDelay`.
- `links > 0` decides `unref`.
- A `stopped` check comes after an `await` in `attempt`.
- There are three flags (`timer`, `attempting`, `stopped`) guarding re-entry.
- The comments explain each rule, so it was resolved, but it is order-sensitive.
8. **`src/send-window.ts:42` (`release`).** Handing a slot to a waiter without decrementing `inFlight` is correct but not commented. I had to reason out that the slot transfers.
## 2. The unit I would least want to modify
`Reassembler.collect` / `weighed` / `dropped`. A change to eviction order inside `ExpiringGroups.weigh`, or to when `get()` expires, silently changes the loss counts reported to the application, which are sessionError events. Nothing at the call site tells me that the coupling exists.
## 3. Expected hard, found easy
- **`PduFramer`:** short, one clear purpose.
- **`PendingRequests` and `OutgoingRequests`:** the split between `LinkLostError` ("never written, retry") and `UnansweredError` ("may have been taken") is named well. `SmppClient.send`'s retry loop (`client.ts:143`) read at once.
- **The layering of Session, IncomingRequests and SessionPort:** the port type (`incoming-requests.ts:50`) says exactly what the collaborator may touch.
- **`server.ts` `handleRequest`:** bind-before-anything is compact.
- **`defs/types.ts`:** long but repetitive and uniform.
## 4. Prose debt
**What I needed, and what it cost to find:**
- **README "Glossary":** needed for ESME/SMSC, `esm_class`, `data_coding`, UDH and `sar_*`. It sits about 600 lines down the README and nothing in `src/` points at it.
- **README "Receiving in depth" and "Server in depth":** needed to understand the answer-on-arrival model before `onMessage` made sense.
- **AGENTS "GSM 7-bit is sent unpacked":** needed to believe the 153/134 budget in `message.ts` (the `segmentUnits` constant near the top).
- **The `ASCII` encoding name:** it means GSM 03.38. Only the comment in `encodings.ts` near line 45 and a README table say so. The name lies.
- **Bit layout of `esm_class` and `data_coding`:** not documented anywhere I was allowed to read. I would need the spec.
**Prose that told me nothing the code did not:**
- AGENTS' architecture map repeats most file-level doc comments almost word for word. It was still useful as an index.
- Several Conventions paragraphs about tests (`dummy-smsc`, `recordingDeps`) were irrelevant to reading `src/`.
- AGENTS' decision index names `ReconnectOptions`, which does not exist in `src/`. The type is `ReconnectTuning`. That line is stale.
- Comments that add nothing:
- `'Whether the socket has closed.'` on `closed` (`session.ts:148`);
- `'Sends a request and resolves with the peer's response.'` (`session.ts:172`);
- `'Connects to an SMSC and binds.'` (`client.ts:414`).
## 5. Scores
| Dimension | Score | Anchor and cause |
|---|---|---|
| Navigation | 7 | Predictable. AGENTS' one-line-per-file map and honest file names (`pdu-framer`, `dlr-merger`, `send-window`) took me from a symptom to a file first time. `ASCII` meaning GSM and the three meanings of "refuse" and "answer" (`Session.refuse`, `IncomingRequests.refuse`, `sendReturn` / `answer` / `sendResp`) keep it off 8. |
| Locality | 5 | Honest middle. The getters in `ExpiringGroups` fire callbacks that emit on the session. The `weighing` side-channel field depends on synchronous re-entry. The mutable `answer` closure is shared by `sendResp` and `sendDlr`. Changing one piece means holding its callback chain. |
| Shape | 6 | Between honest middle and predictable. Fan-out is bounded (Session builds four collaborators; IncomingRequests builds two). Some names mislead: `ASCII`; `stopping`, which the peer's unbind also sets; `over` versus `closed`; `SessionListener`'s emit guard copied three times. |
| Self-sufficiency | 6 | Between honest middle and predictable. Inline spec citations ("SMPP 3.4 5.3.2.26", "4.6.2") and why-comments mostly carry it. The answer-on-arrival model and the data_coding bit groups still need the README or the spec open beside the code. |
| Overall | 6 | Capped by Locality (5 + 1). |
**Intrinsic difficulty:** the problem is hard, and gets no bonus in these scores. It involves concurrency, the protocol's split between UDH and `sar_*`, receipts that look like messages, and the GSM alphabets.
SCORES nav=7 loc=5 shape=6 self=6 overall=6
## Draft F, mid seat
1. **Hardest places, ranked hardest first**
- **`src/reassembly.ts:330` `Reassembler.weighed()` / `dropped()` (:344), together with `src/expiring-groups.ts:38-54` (the `full`, `size` and `weight` getters).** `weighed()` sets `this.weighing` so that `dropped()`, reached re-entrantly through `ExpiringGroups.weigh()` → `dropOldest()` → `onDrop`, knows to subtract the newest segment from the loss count. That is a side channel through a field. On top of it, every getter on `ExpiringGroups` runs `expire()`, which fires `onDrop`. So reading `size` in a log line (`reassembly.ts:358`, `this.groups.weight` inside `lost()`) can drop other groups and report them mid-report. The comments at :240 and :274 got me to what it intends. Whether the re-entrant drops are harmless stayed unresolved.
- **`src/session.ts:211-332` `Session.unbind()` / `drain()` / `finish()` / `end()`.** Three flags carry a lifecycle: `over`, `stopping` and the `ended` promise. `closed` is a getter over `over`, and `port().end` (:262) sets `stopping` from outside the drain. `unbind()`'s `droppedOnUnbind` depends on `UnansweredError` and `this.over` agreeing after an await. The field comments (:64, :66) and the class doc resolved which flag means what, but only after I built a table by hand.
- **`src/client.ts:515` `SmppClient.send()`.** It loops on `LinkLostError`. Whether it terminates depends on `ReconnectLoop.bound()` (which hands back the current session until the socket's `close` event), `Session.send()` (which checks `over`, set only on `close`) and `PduTransport.write()` (which fails on `sock.destroyed`). A socket that is destroyed but has not yet emitted `close` looks to me like it gives a loop that resolves only through microtasks and may never yield. I could not rule that out without a test. This is the plainest action-at-a-distance in the codebase, and it stayed opaque.
- **`src/incoming-requests.ts:260-351` `onMessage()` → `handOver()` → `refuse()`, with `src/sms.ts:796-860` `createSms()` / `sendResp()`.** I had to hold several things at once:
- whether the message was answered on arrival;
- whether the handler threw;
- whether it returned `{ smsId }` or `{ status }`;
- the `Answer` object mutated inside the closure;
- `carriedAs` choosing the retry status.
`alreadyAnswered` has four error texts for these combinations. The README section "Receiving in depth" resolved it; the code alone did not.
- **`src/defs/encodings.ts:152-191` `messageClassOf()`, `messageClassEncoding()`, `encodingByDataCoding()`.** This is bit arithmetic on a GSM 03.38 layout I have never seen. There is a `//` comment and then a `/** */` comment stacked on one function (:160-162), and the `//` one reads as though it belongs to the function above. The comments give the bit positions. Why 0x03 means one thing below 0x80 and another in a class group stayed half opaque.
- **`src/dlr.ts:157-238` `messageType()` / `receiptStatus()` / `dlrFromPdu()`.** `'unmarked'` versus `'receipt'`, whether the TLV or the body wins, and `statusMsg` possibly being `undefined` before it falls back to `'UNKNOWN'`. The README's Delivery receipts section resolved it. Without the README I would have guessed wrong about `'other'`.
- **`src/pdu.ts:323-375` `resolveShortMessage()` / `resolveBody()`.** The `CodingSource` naming feels inverted: an empty `short_message` yields `source: 'message_payload'`, meaning "`message_payload` may set `data_coding`". The comment at :313 explains it, but I had to read it twice.
- **`src/reconnect-loop.ts:83` `schedule()`.** It resets the backoff only if `upAt` shows the link outlasted `maxDelay`, clears `upAt`, doubles the delay after capturing it, and calls `unref()` only once a link has existed. Each step has a comment, which resolved it. It is dense rather than opaque.
2. **The unit I would least want to modify: `ExpiringGroups` (`src/expiring-groups.ts`).** Three owners (`Reassembler`, `DlrMerger` with its `spent` store, and `RunningHandlers`) depend on when drops happen and in what order. Reads mutate state and fire callbacks. `set()` can evict. `weigh()` can evict the entry being weighed. Any change moves loss accounting, drain wake-ups and receipt-merge refusal all at once. Note also that `RunningHandlers` logs "giving up on a handler that never returned" for an *eviction*, not only an expiry.
3. **Expected hard, found easy**
- The wire codec: `defs/types.ts`, `pdu.ts` parse/build, TLV read/write. It is long but regular: every reader range-checks, and every error names the parameter.
- `PduFramer`.
- `PendingRequests` and `SendWindow`.
- The server's bind handling (`handleRequest`).
- The option validation in `session-options.ts`.
Result-typed code with no throws made control flow easy to follow.
4. **Prose debt**
- **Needed:**
- the README Glossary for ESME, SMSC, `esm_class`, `data_coding`, UDH and `sar_*` (cheap to find, essential for me);
- the README "Receiving in depth" and "Server in depth" sections, for the answer rules in `sms.ts` and `incoming-requests.ts` (about 10 minutes to locate);
- the AGENTS "GSM 7-bit is sent unpacked" section, for `segmentUnits` 153 versus 134.
The AGENTS decision index names choices, such as "A message id base is merged at most once", whose reasoning lives in `docs/decisions.md`, which I was not allowed to open. For a few (the `spent` store and `LinkLostError` retry), the title alone left me unsure whether the behaviour I saw was the intent. The charter also says `IncomingRequests` and `Sms` get "named functions, never the session itself". That is false as written: `SessionPort.session` and `Sms.session` both hand over the full `Session` (`incoming-requests.ts:65`, `:293`).
- **Told me nothing:**
- most AGENTS architecture one-liners, which restate the file names;
- the seven `declare` listener lines, repeated in all three emitters;
- `/** Whether the socket has closed. */` on `closed`;
- `defaults.ts`'s "README's option tables restate the public ones";
- many decision-index bullets that simply restate what the code shows, such as "`reconnect` takes `{ minDelay, maxDelay }`…".
Intrinsic difficulty: moderate to high. Two alphabets' bit layouts, two concatenation spellings, and receipts sharing a command with messages are the problem's own difficulty, not the code's. They get no bonus.
5. **Scores**
- **Navigation: 7.** At the "predictable" anchor: `src/` is flat, file names follow concepts (`dlr-merger`, `pdu-framer`, `send-window`), and the AGENTS map matched the tree exactly. It stops short of 8 because of placements like `leftOf` in `idle-waiters.ts` and `refusedSegmentStatus` exported from `incoming-requests.ts`.
- **Locality: 5.** At the "honest middle": getters with side effects in `ExpiringGroups`, the `weighing` side channel in `Reassembler`, and `SmppClient.send()` being correct only across the timing of three modules are all state changed out of sight.
- **Shape: 6.** Between 5 and 7. Files are small and fan-out is bounded, but the vocabulary is overloaded:
- answering: `answer`, `sendReturn`, `sendResp`;
- refusing: `refuse` in two classes with different meanings, plus `refusedAtBound`;
- ending: `over`, `closed`, `stopping`, `end`, `finish`, `ended`;
- and `SessionPort` claims a narrow seam while carrying the whole `Session`.
- **Self-sufficiency: 6.** Between 5 and 7. Comments cite SMPP sections and state the why in place (for example `respIdParams` and `refusalStatus`). But the answer rules and receipt classification needed README sections open beside them, and the decision index points at reasoning that is not in the code.
- **Overall: 6.** Capped by locality (5 + 1). The code reads cleanly line by line; what costs is the few places where state moves out of sight.
SCORES nav=7 loc=5 shape=6 self=6 overall=6
## Draft F, senior seat
1. **Hardest places, ranked**
1. **`src/incoming-requests.ts:260-351`, `IncomingRequests.onMessage` / `handOver` / `refuse`, plus `refusedAtBound` at :224.** Before I could predict the answer to one inbound PDU, I had to hold eight branches at once:
- the running-handler bound;
- concatenated or whole;
- kept or refused, and full or unplaceable, with the refusal status differing between sar and UDH;
- whole or partial;
- link already closed;
- no `onSms`;
- the handler threw or returned;
- answered on arrival or not.
"Who answers the peer, and when" is spread over `onMessage`, `handOver`, `refuse`, `createSms(answeredAs)` and `sms.sendResp`/`alreadyAnswered` in another file (`sms.ts:106-144`). `refusedAtBound` returns a boolean but also writes the answer and flips the `refusing` flag. The comment at :256 and README "Receiving in depth" / "Server in depth" resolved it, but only after two passes.
2. **`src/reassembly.ts:91,113-137,180-198`, `Reassembler.collect` / `weighed` / `dropped`.** The `weighing` field is a side channel. It is set around `groups.weigh()` so that the synchronous `onDrop` callback, which re-enters `dropped()`, can subtract the newest segment from the reported loss. `collect` also inserts the segment into `group.parts` before it knows whether the group survives the weighing. The comments at :90 and :124 made it resolvable, but only by tracing the re-entrancy by hand.
3. **`src/expiring-groups.ts:250-266,295-306,323-334`, the `ExpiringGroups` getters `full` / `size` / `weight` and `weigh`.** Reading a property runs `expire()`, which fires `onDrop` callbacks. Through `RunningHandlers` (`running-handlers.ts:259`) that means a plain read of `this.running.size` inside a log call in `refusedAtBound` (`incoming-requests.ts:229`) can log a "giving up on a handler" warning and wake a drain. The class comment says "the expired go on every access". Nothing at the call sites marks it. This stayed partly opaque: I am not certain every caller tolerates it.
4. **`src/session.ts:211-221,291-332`, `Session.unbind` / `drain` / `finish` / `end`.**
- There are three lifecycle flags: `over`, `stopping` and `bind`.
- `stopping` is also set from outside, through `SessionPort.end` (:262).
- `drain` reads the same state as `this.over` at :294 and as `this.closed` at :303.
- `unbind` deliberately bypasses `send()`'s stopping check by calling `outgoing.request` directly, uncommented.
- `droppedOnUnbind` needed its docblock plus the charter's decision index ("a close arriving after our own unbind is clean") before I trusted it.
5. **`src/client.ts:143-156,202-217,415-438` with `src/reconnect-loop.ts:64-153`, `SmppClient.send` retry loop / `takeFirst` / `keepTrying` / `ReconnectLoop`.** The `LinkLostError` contract spans four files. `send-window.close` sets it for queued requests and `OutgoingRequests.attempt` sets it on a write failure (`outgoing-requests.ts:269,287`). `SmppClient.send` consumes it, with `ReconnectLoop.bound`/`release` in between. The first session is opened outside the loop and then `adopt`ed. `links === 1` means "first", and `links > 0` decides `unref()`. Resolved by the `LinkLostError` class doc and the README's "Sends and the link".
6. **`src/pdu.ts:74-136`, `resolveShortMessage` / `resolveBody`.** The rule for which of `short_message` and `message_payload` may overwrite `data_coding` uses `CodingSource`. An empty Buffer counts as `message_payload`, and only a non-empty encoded `short_message` rewrites the coding. I read it three times. The `CodingSource` doc resolved it.
7. **`src/defs/encodings.ts:476-515`, `messageClassOf` / `messageClassEncoding` / `encodingByDataCoding`.** This is bit arithmetic over GSM 03.38 coding groups that I had no background in. A `//` comment and a `/** */` for different concerns are stacked on one function (:484-486). The comments carry the rule, so it was domain cost rather than code cost.
8. **`src/defs/tlvs.ts:103-126,284-306`, the `WriteValue` / `Repeated` conditional types and `keyedTlvs` → `isTlvs`.** The type layer takes a slow read. The runtime re-validation that ends in "a defect in this library" exists only to avoid a cast. I understood why only through hard rule 4 in the charter.
2. **The unit I would least want to modify:** `IncomingRequests.onMessage`/`handOver` together with `sms.ts` `sendResp`/`alreadyAnswered`. The invariant "every PDU gets exactly one answer, and no answer names an id or refusal after the segments were answered" is not stated in one place. It is enforced jointly by the `answeredAs` argument, the mutable `answer` object captured in `createSms` closures, the `closed()` check placed after `createSms`, and `refuse`'s `answeredOnArrival` branch. A change to any one of these can double-answer or silently drop, and only a test would tell me.
3. **Expected to be hard, found easy:**
- The codec: `pdu.ts` read/write, `defs/types.ts` wire types, `pdu-framer.ts`. It is long but uniform and bounds-checked the same way everywhere.
- `PendingRequests`, `SendWindow` and `IdleWaiters`: small, one job each.
- `dlr.ts` receipt parsing, with the operator quirks commented inline.
- `send-sms.ts`: a linear checklist, then fan-out.
- The server bind composition in `server.ts:508-533`.
4. **Prose debt**
- **Needed:**
- README "Receiving in depth" and "Server in depth", for answered-on-arrival semantics and the retry statuses. Found by the table of contents, so the cost was low.
- The charter's architecture map. It was the most valuable document: every file is listed with a truthful one-liner.
- The decision index titles. They told me that behaviours like the unbind-close and five-minute handler were deliberate. With `docs/decisions.md` off-limits, a title was sometimes all I had.
- I opened no tests.
- **Told me nothing new:**
- The AGENTS.md "Defects found in 0.4.0" table (history, irrelevant to reading `src/`).
- The long test-fixture convention paragraphs.
- README "Methods", which lists names already typed.
- Comments that restate code:
- `session.ts:148` "Whether the socket has closed."
- `session.ts:172` "Sends a request and resolves with the peer's response."
- `server.ts:631` "Starts listening… Resolves once the socket is bound."
- `tlvs.ts:14` "Ordered by tag id", which repeats the charter.
- The seven `declare` listener lines, copied across three emitters, are boilerplate rather than prose, but they are reading cost all the same.
5. **Scores**
- **Navigation 8:** between "predictable" and "near duress-proof". AGENTS.md's file map matches `src/` one-to-one, and names like `pdu-refusal.ts`, `send-window.ts` and `dlr-merger.ts` lead from a symptom to the file first try. The detour is the answer path, split across `incoming-requests.ts` and `sms.ts`.
- **Locality 6:** between "honest middle" and "predictable". The seams are named and narrow (`SessionPort`, `SmsDeps`, `SendSmsDeps`, `LinkLostError`). Three things still break locality:
- `ExpiringGroups` getters fire callbacks on read.
- `Reassembler.weighing` is a re-entrancy side channel.
- `Session`'s flag trio is mutated through `port.end`.
- **Shape 7:** "predictable". No file is past about 440 lines and fan-out per level is small. A few names mislead:
- `ExpiringGroups` is used for running handlers and spent ids, which are not groups.
- `closed` and `over` are two names for one state.
- `refusedAtBound` answers the peer as a side effect.
- `ReconnectLoop.attempt`'s comment calls the library's own connect and bind "the application's".
- **Self-sufficiency 7:** "predictable". Comments carry the SMPP section and the why at the non-obvious points (`sms-id.ts:623`, `message.ts:13`, `dlr.ts:247-253`, `pdu-refusal.ts:446`). The answered-on-arrival contract is the one thing that needed the README open beside the code.
- **Overall 7:** capped at 7 by locality. A cold senior is productive within a week and knows which corners to fear. Intrinsic difficulty is moderately high (protocol quirks plus a concurrent drain and reconnect), and that earns no bonus.
SCORES nav=8 loc=6 shape=7 self=7 overall=7
## Draft F, architect seat
**Architect, inherited: comprehension report on @larvit/smpp (draft-f)**
## 1. Map from README and tree only (verbatim, written before opening any source)
Top-level areas I expect, although `src/` is flat and shows none of them:
1. **Public handles**: `index.ts`, `client.ts` (SmppClient, reconnecting), `server.ts` (listener, one Session per connection), `session.ts` (one socket's life), `sms.ts` (the inbound `sms` handle with sendResp/sendDlr).
2. **Session machinery**: `link-timers` (enquire_link and idle), `reconnect-loop` (backoff), `send-window` (maxOutstanding), `pending-requests` (seqNr correlation and timeout), `outgoing-requests` (window plus pending), `incoming-requests` (dispatch of peer requests), `running-handlers` (onSms handlers in flight, "Handlers still running" in the README), `idle-waiters` (maybe the idle timeout?), `pdu-transport` and `pdu-framer` (socket to PDUs).
3. **Codec**: `pdu.ts`, `pdu-refusal` (PduRefusedError), `retained-pdu` (a PDU kept for retry?), and `defs/` for the spec tables and wire types.
4. **Message content**: `message.ts` (encode, split, bitCount, smppTime), `message-body` (short_message vs message_payload), `concat` and `udh` (probably the reading and writing of concatenation), `reassembly`, `expiring-groups` (the reassembly store?), `send-sms` (submit composition).
5. **Receipts**: `dlr.ts`, `dlr-merger` (messageDlr), `sms-id` (notations, `<base>-<n>`).
6. **Plumbing**: `result`, `log`, `error-from`, `uuid`, `defaults`, `session-options`, `unanswered-error` (went out, no answer), `link-lost-error` (never reached the socket).
Names that do not give their purpose: `idle-waiters`, `retained-pdu`, `expiring-groups`, `error-from`, and `defaults` vs `session-options`.
What the README led me to expect: a store interface (goal 9). The README itself says it has not shipped, so its absence is fine.
## Where the map was wrong, and what each correction cost
- **`idle-waiters`** is a primitive that waits for a count to fall to zero. It is not the idle timeout, and it also exports `leftOf()`, the deadline arithmetic `client.ts` uses. Cost: low. The misleading part is `leftOf` living there.
- **`retained-pdu`** copies a PDU off the wire so holding it does not pin the chunk, and weighs what holding it costs. It has nothing to do with retry. Cost: low. The copy invariant is split with `defs/tlvs.ts:250`, which already copies TLVs, so `retained-pdu.ts:5`'s claim that "wire reads hand back views" is only half true.
- **`expiring-groups`** is a generic capped, weighed, expiring map. Besides reassembly it also backs `DlrMerger` (twice) and, surprisingly, `RunningHandlers` as a counter (`ExpiringGroups<true>` keyed by serial). Cost: medium. "Groups" misleads for the handler counter.
- **`concat` / `udh`**: splitting is in `message.ts`. `udh.ts` holds the outgoing `ConcatReference` counter plus the parse `concatInfo`. `concat.ts` chooses between UDH and `sar_*`. Cost: medium. It took three files to place the concatenation concepts.
- **`session-options`** is not only options. It also holds bind-direction policy (`bindCarries`, `standsInFor`), `SessionEvents`, the hook types, and validation for client- and server-only options (`authenticate`, `connectTimeout`, `reconnect`, `fromStart`). Cost: medium. I would never have looked there for "which way does a data_sm travel".
- **`pdu.ts` depends on `message.ts`** (`encodeBody`, `decodeMessage`), which depends on `udh.ts`. So the codec sits above the message layer, not just above `defs/`. Cost: low, but the layering in the AGENTS text is incomplete.
- The rest of the map held.
## Fan-out, level by level
- **L0, the repo:** `src`, `test`, `docs`, `benchmarks`, `interop-tests`, plus about 8 top-level `.md` files. Fine.
- **L1, `src/`:** 36 files plus `defs/`, so 37 entries. **This is the worst level.** About six real areas exist, but the layout shows none of them. The only map is the Architecture block in AGENTS.md.
- **L2, `defs/`:** 7 files, clean.
- **L3, the big units:**
- `Session` composes 4 collaborators plus a hand-built `SessionPort` of 12 members.
- `IncomingRequests` holds `Reassembler`, `RunningHandlers`, `createSms`, the port and the hooks, and imports 23 symbols.
- `SmppClient` holds `ReconnectLoop`, `DlrMerger` and `ConcatReference`, plus about 200 lines of free connect and bind functions.
## Names
**Names that mislead**
- `IncomingRequests.drain` (`incoming-requests.ts:147-153`) calls the count of running handlers `unanswered` and reports "Shut down with N message(s) unanswered". A handler that already called `sendResp()` is still counted. That is the 3am bug's own error text pointing the operator at the wrong thing.
- `RunningHandlers`' `onDrop` logs "giving up on a handler that never returned" for any drop, including an `evicted` one. Eviction can only be avoided because `refusedAtBound` is checked first, somewhere else (`running-handlers.ts:28`).
- `session-options.ts`, as above.
- `decodeSegments` lives in `reassembly.ts` but is used by `sms.ts`.
- `ExpiringGroups` used as a counter.
**One name over several concepts**
- **refuse:** `Session.refuse` (codec-refused PDU), `IncomingRequests.refuse` (application did not take the message), `refusedAtBound`, `Refusal` (a reassembly slot), `refusedSegmentStatus`, `refusalAnswer`, `PduRefusedError`.
- **end:** `Session.end`, `OutgoingRequests.end` and `IncomingRequests.end` all mean "the socket is gone". `SessionPort.end` means "the peer unbound, destroy the socket". `SmppClient.end` means "the client is over".
- **answer:** `SessionPort.answer` writes a response. `sms.ts`'s `Answer` is mutable answered-state. `answerOf` converts the handler's return.
- **closed:** a public getter on `Session`, a private field on `SmppClient`, and a port function.
**One concept with two names**
- Session lifecycle: `over` / `closed`, and `stopping` / "shutting down".
- The UDH indicator is checked through `hasUdh` in 5 places, and the body is sometimes `params.short_message` (a string, or a Buffer when a UDH is present) and sometimes `shortMessageOctets`.
- The submit bind check is spelled twice: `client.ts:159` reads `options.bindType`, `session.ts:195` calls `bindAllows`.
## Restructure, ranked
1. **Split `IncomingRequests`.** Routing (`route`, `unhandled`, `onDelivery`) is one piece. Message intake (bound refusal, reassembly, answer-on-arrival, `handOver`, `refuse`) is a second, called something like `MessageIntake`. It is the densest unit and the one that grows.
2. **Carve bind-direction policy out of `session-options.ts`** into `bind-direction.ts`, and move client/server option validation next to its owners or into an `option-checks.ts`.
3. **Make the "drain waits on" counter say what it counts.** Either release it on `sendResp()` plus pending receipts, or rename the error to "handlers still running". Also stop using `ExpiringGroups` as the handler counter.
4. **Put concatenation in one module:** `ConcatReference`, `concatInfo`, `concatOf`, `udhLength`, and the UDH build in `splitMessage`.
5. **Group `src/` into 4–5 folders:** handles, link, codec, message, receipts. The flat 37 files is the main Shape cost.
6. **Extract the emitter guard.** The `emit` override, `captureRejectionSymbol` and 7 `declare` lines are copied three times (Session, SmppClient, SmppServer).
## What the structure gets right
- Narrow seams: `SessionPort`, `SmsDeps`, `SendSmsDeps`, and the `ReconnectLoopOptions` callbacks. Collaborators do not reach into the Session.
- Every unit is small and single-noun (`SendWindow`, `PendingRequests`, `LinkTimers`, `PduFramer`).
- `Result` is used everywhere, so control flow reads top-down.
- Comments carry spec sections and the peer quirks behind them (Jasmin, CM.com, Kaleyra).
- `defaults.ts` is the single source of numbers.
- `LinkLostError` vs `UnansweredError` encodes goal 2 in the type.
## The 3am question
**Time to the right unit, cold: about 5–10 minutes, three hops.**
- Hop 1: grep "drain" lands in `session.ts:291`, `Session.drain`.
- Hop 2: that calls `this.incoming.drain(timeout, signal)` at `incoming-requests.ts:146`.
- Hop 3: that calls `RunningHandlers.idle` (`running-handlers.ts:63`), and I had to find where `start()` and `done()` are called: `IncomingRequests.handOver`, `incoming-requests.ts:311-314`.
**The answer:** `done()` fires when the `onSms` handler *returns*, not when `sms.sendResp()` is called. `sendResp` (`sms.ts:126`) never touches `RunningHandlers`. So a handler that answers early and keeps working holds the drain until `shutdownTimeout`. That includes a handler awaiting `sendDlr()` to a slow peer: `sendDlr` goes through `port.request`, which bypasses the stopping check, and the outgoing drain then waits on it too.
- **Is it a bug?** README line 399 documents "Wait … for every onSms handler still running", so it is by design. The `sendResp` docstring ("for a handler that keeps working after the answer") invites exactly this expectation.
- **Right file and unit:** `incoming-requests.ts`, `handOver`, together with `running-handlers.ts`.
- **What slows the hunt:** the reported error, "message(s) unanswered", is false for this peer and costs an extra detour into `sms.ts`.
**Where it rots first:** `IncomingRequests.onMessage` / `handOver`. Every new inbound rule lands there (per-PDU rate limiting, the store for half-reassembled messages, new `data_sm` semantics), and each one adds another `port.closed()` check and another answer path.
**Where the next two features would land**
- **Goal 9's store** would land across `Reassembler`, `DlrMerger` and `ExpiringGroups`. `ExpiringGroups` is the obvious seam, but it is shared with the handler counter, which must not be persisted. Separate them first.
- **A per-PDU rate limit (goal 7)** would land in `OutgoingRequests.request` beside `SendWindow`. That is a clean place. The inbound side would land in `IncomingRequests` again.
## Hardest places, ranked
1. `src/incoming-requests.ts:260-326`, `IncomingRequests.onMessage` / `handOver`. Bound refusal, reassembly, answer-on-arrival, the handler run, and refusal-or-loss are interleaved with `closed()` checks and `sms.sendResp` side effects.
2. `src/reassembly.ts:179-198`, `Reassembler.weighed` / `dropped`. The transient `weighing` field is read inside an `onDrop` callback to discount the newest segment. That is action at a distance through a callback.
3. `src/session.ts:291-310` with `incoming-requests.ts:146` and `running-handlers.ts:50-75`, `Session.drain`. It orders handlers, then requests, on a shared deadline (`timeout` for the first, `leftOf(deadline)` for the second), then checks `closed`. The meaning of "unanswered" is wrong.
4. `src/expiring-groups.ts:111-122`, `ExpiringGroups.expire`. It fires `onDrop` mid-iteration. `RunningHandlers.onDrop` → `settle()` → `size` → `expire()` re-enters it.
5. `src/reconnect-loop.ts:64-153` with `client.ts:143-156`, `ReconnectLoop.adopt` / `attempt` / `down` / `stop` and the client `send` retry loop on `LinkLostError`. The state lives in `session`, `timer`, `attempting`, `stopped`, `upAt` and `links`.
6. `src/pdu.ts:84-136`, `resolveShortMessage` / `resolveBody`. The rules for which of `short_message` or `message_payload` owns `data_coding`.
7. `src/sms.ts:126-231`, `sendResp` / `sendDlr` over the shared mutable `Answer` object.
**The unit I would least want to modify:** `IncomingRequests`, `src/incoming-requests.ts:87-358`.
## Scores
Intrinsic difficulty, which earns no bonus: moderately high. It is an async request/response protocol with reassembly, reconnect, a drain and two ends of the link.
- **Navigation 7.** Sits at "Predictable". File names map to concepts well enough that the 3am path took three hops. It is held below 8 by the flat 37-file `src/` and by the drain's "unanswered" error text pointing at `sendResp`.
- **Locality 6.** Between 5 and 7. The narrow ports (`SessionPort`, `SmsDeps`) keep collaborators apart. Hidden coupling holds it down: `RunningHandlers` evicting live handlers unless `refusedAtBound` runs first, the reassembler's `weighing` side channel, and the drain's ordering and shared deadline.
- **Shape 6.** Between 5 and 7. Units are small and mostly honest. Held down by the flat `src/` with no visible areas, `session-options.ts` as a grab bag, concatenation spread over three files, and the overloaded refuse/end/answer/closed vocabulary.
- **Self-sufficiency 7.** Sits at "Predictable". Most units state their invariant and cite the SMPP section at the site (`message.ts:13`, `pdu.ts:166`, `sms-id.ts:58`). Held below 8 because the area map and the import direction exist only in the AGENTS.md architecture block, not in the layout.
- **Overall 6.** Capped at 7 by the lowest dimension plus one. It sits at 6 because the unit that grows, `IncomingRequests`, is the hardest one to change safely.
SCORES nav=7 loc=6 shape=6 self=7 overall=6
-287
View File
@@ -1,287 +0,0 @@
# Plan 1: the application developer's mental model as the source tree
Lens: an application developer thinks in six verbs: *connect* (client), *listen* (server), *send*,
*receive*, *get a report*, *shut down*, plus *read PDUs* when they go low-level. Every directory is
one of those verbs, in the README's order, and every boundary between them is a seam a developer
already knows exists.
## 1. Principles
1. **The README's table of contents is the directory listing.** `Send an SMS` → `sending/`,
`Delivery reports` → `receipts/`, `Receive SMS` → `receiving/`, `Run an SMPP server` → `server/`,
`Client options`/reconnect → `client/`, `Session` → `session/`, `PDUs and the low-level API` →
`wire/`, `Encoding`/long messages → `text/`. Answers: Navigation stuck at 6 in every round, while
C, the one draft with grouped folders, reached Locality 6 on every seat.
2. **One socket, one `Session`, one life.** A `Session` goes `open → bound → closing → closed` and
never backwards. Reconnect is a separate `ClientSession` above it, whose state is a discriminated
union holding a `Session` only while one is up. Answers lesson 3: `LinkLife`'s phase plus
`stopped`, seven predicates, call-order-dependent `linkLost`/`end`/`dropSocket`, and
`ReconnectLoop`'s duplicate stopped flag. F showed that the split removes the unit; this plan keeps
F's split and fixes what F left, below.
3. **Every invariant has one owner, and that owner's file states it.** "Every inbound request gets
exactly one answer" → `session/owed-answer.ts`. "Nothing held without a bound" →
`limits/bounded-store.ts`. "A send that never reached a socket may go out on the next one" →
`client/next-link.ts`. Answers round three's "enforced jointly by `IncomingRequests` and
`sms.ts`", and D's "answered in three places".
4. **Lower areas never call up; they declare the port they need.** `receiving/`, `receipts/` and
`sending/` export functions and classes that take a narrow port type *declared in their own
file* (`AnswerPort`, `SendPort`, `report(err)`). `Session` implements the ports. Answers finding 4:
`IncomingRequests`/`HeldMessages` calling `emit`, `sendReturn`, `close` and `listenerCount` on
`Session`.
5. **A store enforces its own bound.** `BoundedStore` evicts, expires and weighs by itself, never
evicts the entry being admitted, keeps reads pure, and reports every removal through one
`onRemoved(key, value, reason)`. Answers round three's most-cited unit: `ExpiringGroups` plus
`Reassembler.trim`.
6. **A state change's effects sit inside the transition that causes them.** No reducer that returns
effects, and no callbacks wired from another file. Answers E, whose state machine scored no better
because each transition's meaning was split over five callbacks.
7. **Every SMPP term is glossed once, and every spec citation carries its half-line summary.**
`SMPP 3.4 5.2.12 (esm_class: message type and mode bits)`, never a bare `5.2.12`. The README gets
a glossary table (ESME, SMSC/MC, PDU, bind, esm_class, data_coding, UDH, sar_*, TLV, receipt,
segment). Answers lesson: juniors score Self-sufficiency 5 because of bare citations and
`data_coding` bit masks.
8. **Every default in one file.** `src/defaults.ts`, grouped by the verb that uses them. Answers
"defaults spread over several files".
9. **Hard parts are marked by one convention.** A hard part opens with an `Invariant:` paragraph at
the code it guards, which the comment rules permit, and AGENTS.md gets a "Where it is hard" list
naming the file of each. The panel's "7" asks for hard parts that are few, localized and marked.
## 2. Layout
Dependencies point downward in this order: `wire` ← `text` ← `limits` ← {`sending`, `receiving`,
`receipts`} ← `session` ← {`client`, `server`} ← `index.ts`. Root files are importable by all.
The only ways up are ports: types declared low, implemented by `session/`.
```
src/ fan-out 13: 4 files, 9 areas
index.ts Public surface, named exports only. Grouped by the README's sections.
defaults.ts Every default value, grouped by client/server/session/stores. No logic.
log.ts SmppLog, silentLog, guardedLog.
result.ts Result<T>, VoidResult, errorFrom(), namedValue(), UnansweredError.
wire/ "PDUs and the low-level API". Fan-out 6 + tables/
pdu.ts pduToObj/objToPdu/pduReturn/isCommand/isResp. Stateless, total.
framer.ts PduFramer: byte stream → complete PDUs. State: the partial buffer.
refusal.ts PduRefusedError, PduHeader, the status SMPP names for each refusal.
retained-pdu.ts detach() and retainedOctets(): what holding a PDU costs.
smpp-time.ts smppDate/smppTime: the 16-char time format, absolute and relative.
tables/ The spec, as data. Fan-out 7. Knows nothing above it but result.ts.
commands.ts 33 commands, ids, params in WIRE ORDER (invariant stated at the top).
constants.ts consts, constsById, interface versions.
alphabets.ts GSM 03.38 (named gsm7 internally; 'ASCII' only as the public name), LATIN1, UCS2 codecs; detection.
data-coding.ts data_coding → alphabet and message class; one row per coding group with its meaning in words.
errors.ts ESME_* by numeric id.
tlvs.ts TLV table by numeric id; typed read/write of a TLV stream.
types.ts Wire types int8…cstring, arrays.
index.ts defs, the grouped tables.
text/ "Encoding" and "Long messages". Fan-out 3. Pure functions.
message-text.ts encode/decode/bitCount/unencodable; where a PDU's body is (short_message vs message_payload).
splitting.ts splitMessage and segment budgets (153 GSM unpacked / 134 / 67); the "GSM is sent unpacked" note lives here.
concatenation.ts Both spellings, both directions: UDH build/parse, sar_* read, concatOf(), ConcatReference counter.
limits/ What is bounded, and waiting on it. Fan-out 3.
bounded-store.ts BoundedStore<V>: count cap, weight cap, expiry, one sweep timer, onRemoved(key, v, reason). Invariant: admit never evicts its own key; get() never mutates.
send-window.ts SendWindow: the maxOutstanding semaphore; idle(timeout) for the drain.
idle-waiters.ts Waiting for a count to reach zero within a budget.
sending/ "Send an SMS". Fan-out 2.
compose-submit.ts submit_sm params from SendSmsOptions: TON, messaging mode, flash, times, the alphabet check. Pure.
sms-sender.ts SmsSender: split, send every segment together through a SendPort, collect ids, tell the merger. State: ConcatReference, ReceiptMerger.
receipts/ "Delivery reports". Fan-out 5.
receipt-states.ts message_state ↔ stat: codes, FAILED/DELIVERD aliases, which states are transient. Invariant: only ENROUTE/SCHEDULED are not final.
read-receipt.ts dlrFromPdu(), parseReceipt(): esm_class, then TLV, then body.
write-receipt.ts The deliver_sm a sendDlr() sends: text, TLVs, esm_class 0x04 vs 0x20. Pure.
receipt-merger.ts ReceiptMerger: per-segment receipts into one MessageDlr. State: open groups, spent bases (BoundedStore x2). `close` renamed `spend`.
message-ids.ts uuidv7(), <base>-<n>, smsIdFormat normalisation, which response carries an id.
receiving/ "Receive SMS" and "Receiving in depth". Fan-out 4.
receive-message.ts The one path an inbound message takes: bound check → concat? → reassemble → answer segment → hand whole message to handlers. Takes AnswerPort per request.
reassembler.ts Reassembler: incomplete groups in a BoundedStore; lost groups reported once.
running-handlers.ts RunningHandlers: onSms calls in flight. State: count, weight, deadline per call. Invariant: return releases, throw refuses with the retry status, no handler refuses at once. Its SendPort lets a running handler's sendDlr pass the drain.
sms.ts The Sms handle: fields decoded once; sendResp → the message's OwedAnswers; sendDlr → write-receipt via SendPort.
session/ "Session". One socket's life. Fan-out 8.
session.ts Session (public): state 'open'|'bound'|'closing'|'closed' in one field; bound(), send, sendSms, sendReturn, close, unbind; emits. Each transition method carries its own effects.
session-options.ts SessionOptions type and its checks.
transport.ts One socket + framer + write(). No attach(): a socket never changes.
heartbeat.ts enquire_link on quiet, idle timeout. State: two timers.
requests.ts Outgoing: seqNr, pending map, response timeout, window slot, abort, UnansweredError. Merges pending-requests + outgoing-requests minus link logic.
dispatch.ts An inbound PDU's route: response → requests; request → onRequest hook → bind direction → enquire_link/unbind/re-bind/unknown/message/receipt.
owed-answer.ts OwedAnswer: created for every inbound request at dispatch; the only writer of a response. State: owed|answered|lost. Implements AnswerPort.
bind-direction.ts What each bind type carries per link end; data_sm stands in for submit_sm or deliver_sm; checkedBind().
shutdown.ts The drain: handlers first, then requests, one deadline, the err naming what was lost.
client/ "Client options" and reconnect. Fan-out 5.
client.ts client(), ClientSession (public): state {kind:'connecting'} | {kind:'up', session} | {kind:'down', attempt} | {kind:'closed'}. Forwards events of the current Session; owns SmsSender so merges survive a reconnect.
client-options.ts ClientOptions and their checks (connectTimeout, reconnect spelling, fromStart).
connect.ts Open a socket: TCP or TLS, connectTimeout over both.
bind.ts bind_* params, the bind response → session.bound().
reconnect.ts Backoff as a pure function (delay, upSince) → next delay, and the retry timer. No stopped flag: the ClientSession's 'closed' state is the stop.
next-link.ts Sends waiting for a bound Session, one budget each; a send that never reached a socket retries here. Invariant: nothing that reached a socket is resent.
server/ "Run an SMPP server" and "Server in depth". Fan-out 3.
server.ts server(), SmppServer: listener, sessions set, close() = stop listening then drain each.
server-options.ts ServerOptions and their checks.
accept-bind.ts Pre-bind requests, authenticate, bind_resp with sc_interface_version, Session.bound().
```
Deepest level is 3 (`src/wire/tables/`). Maximum fan-out 13 at `src/`, otherwise ≤ 8.
## 3. Public API changes
Three changes; everything else in the README keeps its spelling, including `client()` resolving
`{ err, session }`, so every send and receipt example survives untouched.
1. **Inbound messages reach an `onSms` handler option instead of an `sms` event.**
- Old: `session.on('sms', async sms => { await sms.sendResp(); })`, and a server's
`smpp.on('session', s => s.on('sms', …))`.
- New: `client({ onSms })`, `server({ onSms })`, `new Session({ onSms })`, typed
`(sms: Sms) => Promise<void> | void`. `sms.sendResp()` stays the only way to answer, with the same
options. Returning releases the message, answering `ESME_ROK` first if `sendResp()` was not called.
Throwing or rejecting refuses it with the retry status (`ESME_RTHROTTLED` or `ESME_RX_T_APPN`)
unless it is already answered, and reports it on `sessionError`. With no `onSms`, every message is
refused at once with that retry status and one `warn` is logged. `sendDlr()` before the answer
returns `err`. `sms.answeredOnArrival` stays.
- Removes: the held-message timing contract (lesson 1): listener counts, the `setImmediate` turn,
`captureRejections` routed through a `WeakMap`, and "answered" living in three places, because
`answeredOnArrival` becomes a getter over the OwedAnswers.
- Serves goal 2 (a crash before the answer leaves the peer to resend, unlike C) and goal 5. The
no-handler refusal is a judgement call that goes in docs/decisions.md, resting on goal 2's "work
the peer has no reason to send again is not dropped".
- Migration is one line per listener: the handler body is unchanged.
2. **A `Session` is one socket; reconnect is `ClientSession`, which `client()` returns.**
- Old: `Session` with a `reconnect: { connect, onConnected }` option, `disconnected` and
`reconnected` events, and `sock` swapped under it.
- New: `Session` has no `reconnect` option, no `disconnected`/`reconnected`, and a fixed `sock`.
`client()` still resolves `{ err, session }`, where `session` is a `ClientSession` with today's
client methods (`sendSms`, `send`, `unbind`, `close`, `boundAs`, `peerInterfaceVersion`,
`acceptsOptionalParams()`, `bindAllows()`) and events (`close`, `disconnected`, `reconnected`,
`dlr`, `messageDlr`, `sessionError`, plus `data`/`incomingPdu`/`incomingPduObj` forwarded from
the current link). `session.sock` and `session.sendReturn()` move to `session.link`, the current
`Session` or `undefined` while down, because both belong to one socket. A hand-wired ESME that
wants reconnect builds a new `Session` per socket.
- Removes: `LinkLife` and its predicates, the call-order hazard, `ReconnectLoop`'s duplicate stop,
and client.ts's `bindOn` depending on another file's ordering (lesson 3).
- Serves goal 8 (a smaller surface, a stated scope for `Session`) and goal 4 (one stop state, so no
path rebinds after close).
3. **`linkEnd` becomes a readonly constructor option on `Session`** (old: a writable field
`session.linkEnd = 'smsc'`). A field mutable after dispatch starts is a hidden state a reader has
to chase through `bind-direction.ts`. Serves goal 4, since the bind direction decides what is
refused.
Kept deliberately: `encoding: 'ASCII'` as the public name of GSM 03.38 (inherited from 0.4.0,
MIGRATION.md relies on it). Internally the alphabet is `gsm7` everywhere, and `alphabets.ts` glosses
the public name once. `sessionError`'s kinds stay told apart by type and message: no panel cited them.
## 4. Where each thing goes
| Now | New home |
| --- | --- |
| client.ts | client/client.ts (client(), fromStart), client/connect.ts (openSocket, connectTimeout), client/bind.ts |
| server.ts | server/server.ts, server/accept-bind.ts (authenticate, pre-bind, bind_resp) |
| session.ts | session/session.ts (state, API, emit guard); drain → session/shutdown.ts; dispatch/refuse → session/dispatch.ts |
| sms.ts | receiving/sms.ts (handle, sendResp); receipt building → receipts/write-receipt.ts |
| concat.ts, udh.ts | text/concatenation.ts |
| dlr.ts | receipts/read-receipt.ts, receipts/receipt-states.ts |
| dlr-merger.ts | receipts/receipt-merger.ts (`close` → `spend`) |
| error-from.ts, unanswered-error.ts, result.ts | result.ts |
| expiring-groups.ts | limits/bounded-store.ts (enforcing its own caps) |
| held-messages.ts | receiving/running-handlers.ts |
| idle-waiters.ts, send-window.ts | limits/ |
| incoming-requests.ts | session/dispatch.ts (routing, bind direction, unbind, unknown) + receiving/receive-message.ts (message path, store bound) |
| link-life.ts | deleted: the state goes to session.ts's one field; waiting for a link goes to client/next-link.ts |
| link-timers.ts | session/heartbeat.ts |
| log.ts, result.ts | root |
| message.ts | text/message-text.ts, text/splitting.ts; smppDate/smppTime → wire/smpp-time.ts |
| message-body.ts | text/message-text.ts |
| outgoing-requests.ts, pending-requests.ts | session/requests.ts; the next-link retry → client/next-link.ts |
| pdu.ts, pdu-framer.ts, pdu-refusal.ts, retained-pdu.ts | wire/ |
| pdu-transport.ts | session/transport.ts, without attach() |
| reassembly.ts | receiving/reassembler.ts; decodeSegments → text/message-text.ts |
| reconnect-loop.ts | client/reconnect.ts |
| send-sms.ts | sending/compose-submit.ts + sending/sms-sender.ts |
| session-options.ts | defaults → defaults.ts; checks → each area's *-options.ts; bindCarries/standsInFor/checkedBind → session/bind-direction.ts |
| sms-id.ts, uuid.ts | receipts/message-ids.ts |
| defs/* | wire/tables/*; encodings.ts split into alphabets.ts and data-coding.ts |
Named-hard responsibilities:
| Responsibility | Home and owner |
| --- | --- |
| Held messages and answering | session/owed-answer.ts (one answer per request); receiving/running-handlers.ts (the handler's life) |
| Lifecycle and reconnect | session/session.ts (one-way state); client/client.ts (union state), client/reconnect.ts (backoff) |
| The drain | session/shutdown.ts, one function; a running handler's sends admitted through its own SendPort |
| Outgoing requests and retry | session/requests.ts (one link, no retry); client/next-link.ts (the only retry) |
| Reassembly and ExpiringGroups | receiving/reassembler.ts over limits/bounded-store.ts |
| Receipts and merging | receipts/ (read, states, write, merger); merger owned by SmsSender, which ClientSession holds across links |
| The codec | wire/pdu.ts over wire/tables/ |
| Encodings | wire/tables/alphabets.ts, wire/tables/data-coding.ts; text/ above them |
| Defaults | src/defaults.ts |
| Domain knowledge and glossary | README glossary table; summarised citations; the defect table and "GSM is sent unpacked" stay in AGENTS.md, with a pointer line in splitting.ts |
## 5. The hard parts that stay hard
Each is marked with an `Invariant:` paragraph in its file and listed under "Where it is hard" in
AGENTS.md.
1. **Exactly one answer per inbound request** (session/owed-answer.ts). The hardness is SMPP's: a
multipart message is answered per segment on arrival, a single one when the application says, a
refused PDU from its header alone, and never on a link that is gone. Localized, since OwedAnswer
is the only writer, and a test asserts no other file calls `transport.write` with a response.
2. **The drain's order and budgets** (session/shutdown.ts). Handlers first, because a handler's
answer can put a receipt on the wire. `shutdownTimeout: 0` still bounds the handler half. One
function, about 40 lines, with its budget rule in its signature.
3. **Retry only what never reached the socket** (client/next-link.ts). Goal 2's "never re-sent on
the library's own initiative". The rule is one predicate over the `requests.ts` result
(`written: false`), and the loop lives only here.
4. **Bounded stores and eviction order** (limits/bounded-store.ts). The reassembly weight rule, 1000
plus octets plus 300 per TLV, stays in receiving/reassembler.ts as one function beside its
README-facing constant.
5. **data_coding coding groups** (wire/tables/data-coding.ts). Bit masks are unavoidable. One table
row per group states in words what the group means and whether it carries a class.
6. **Receipt classification** (receipts/read-receipt.ts). esm_class, then TLV, then text, with
operator aliases. It is operator folklore, so each alias names the operator in one line.
## 6. Build order
Each chunk keeps the suite green. Tests move to the new API only in chunks 5 and 6, the two
contract changes.
1. **Tables and codec.** Move defs/ to wire/tables/, split encodings, rename gsm7 internally, and
move pdu, framer, refusal, retained and time into wire/. Add summaries to every citation.
2. **text/ and receipts/.** Pure moves plus `spend`. message-ids absorbs uuid.
3. **limits/.** BoundedStore replaces ExpiringGroups, with its own tests for "admit never evicts
itself" and "reads are pure". Reassembler, merger and held messages port onto it.
4. **defaults.ts, the *-options.ts files, sending/.** SmsSender with its SendPort.
5. **Contract 1: onSms.** Add owed-answer.ts, receiving/, and session/dispatch.ts. Delete
held-messages and incoming-requests. Port the `sms` listeners in tests and README, and add the
decision record for the no-handler refusal.
6. **Contract 2: one-socket Session, ClientSession.** Session gets its one-way state, plus
transport without attach, requests, heartbeat and shutdown. The client gets client/,
next-link and reconnect. Delete link-life. Port the reconnect tests to ClientSession, and make
`linkEnd` an option.
7. **server/** split, index.ts regrouped by README section, the README glossary, AGENTS.md
architecture, and "Where it is hard".
8. **Run the comprehension panel.** Fix only what it names inside the hard-parts list.
## 7. Predicted panel risks
- **ClientSession forwarding** (client/client.ts). It re-emits the current Session's events and
answers `boundAs` through the gap. A reader asks "which object do I listen on?" Mitigation: README
Events table gains one column, `Session` / `ClientSession`. Likely Shape 6 on the senior's seat if
the forwarding list is long.
- **Two SmsSenders in client mode.** A Session inside a ClientSession has its own unused sender, and
the ClientSession's merger is fed from the link's `dlr`. Alternative: Session takes an optional
injected SmsSender. Pick one at chunk 6 and state it in client.ts's invariant.
- **Ports feel like indirection to the junior.** `AnswerPort`/`SendPort` add names. Mitigation: each
port is 1–3 members and declared in the file that uses it.
- **Root fan-out of 13, and `limits/` is a new abstraction name.** A mid may look for the send window
under session/. It is cross-referenced from requests.ts's constructor argument only.
- **The no-handler refusal** surprises a transceiver client that never expected MO traffic: its SMSC
retries forever. That is a goal-2-correct outcome, but a reader may argue it; the decision record
must carry the argument.
- **The coarse scale.** Removing a unit has moved the hardest unit elsewhere every round. The most
likely next candidate is `owed-answer.ts` + `receive-message.ts`, which could hold a mean at 6.5
rather than 7. The mitigation is to keep it the only writer and test that.
-287
View File
@@ -1,287 +0,0 @@
# Plan 2: state ownership first
Every piece of mutable state has one owner. The owner is the only writer, states its invariant in one
paragraph above the class, and enforces it in the same file. Other files read state through the
owner's methods, or learn about it from a result the owner returns. Nothing reaches back up.
## 1. Principles
1. **One object per socket, never reused.** A `Link` is born with a socket and dies with it. Its
phase only moves forward: `binding → bound → gone`, or `binding → gone`. There is no `attach()`, no
`stopped` flag and no initial state that breaks its own rule. This answers lessons 3 and round 3:
LinkLife's 4 phases plus `stopped`, the initial `'up'`, and `linkLost`/`dropSocket`/`comeBackUp`,
whose correctness depended on call order. F showed that a one-socket unit removes the lifecycle as
the hardest spot. This plan keeps that idea behind the **current** public `Session`, so the public
rename F needed is avoided.
2. **Two state fields for the session's life, one per owner, and neither derives from the other.**
`Session.life: 'open' | 'closing' | 'ended'` belongs to `Session`. `Link.phase` belongs to the
`Link`. "Can send now" is `life === 'open' && link?.phase === 'bound'`, written once in
`Session.boundLink()`. The seven predicates of lesson 3 collapse to that one method.
3. **Stoppedness has one writer.** `Session` holds an `AbortController` called `lifetime` and aborts
it in the same statement that leaves `'open'`. The reconnect loop, the waits for a link and the
drain only read `lifetime.signal`. This removes ReconnectLoop's duplicate stopped flag (lesson 3)
and client.ts's "close() must reach stop() before its first await" comment.
4. **A transition finishes before anyone hears about it.** Each transition method writes every field
first and emits last. A listener that calls `close()` from `disconnected` or `close` finds
`life === 'ended'` and gets `{}` back. This answers lesson 3's synchronous re-entry.
5. **The inbound answer has one owner.** `link/answers.ts` is the only code that writes a response
PDU, and it writes at most one per inbound sequence number. `sendReturn()`, bind handling, refusals,
segments answered on arrival and the value an `onSms` handler returns all go through it. This
answers round 3's "exactly one answer, enforced jointly by IncomingRequests and sms.ts", and D's
"answered in three places".
6. **Receiving is a handler, answered on return** (C/D/E/F: the held-message timing contract capped
every seat). The handler's promise is the hold. There is no `setImmediate`, no listener count, and
no WeakMap back from a `captureRejections` payload.
7. **A bounded store enforces its own bounds.** `BoundedStore` checks the count, the weight and the
deadline itself. Reads never mutate. Only `admit()`, `grow()` and its own timer drop entries, and
every drop goes to one `onDrop(value, reason)` given at construction. It never drops the entry the
caller is writing: it refuses that write instead. This answers round 3's ExpiringGroups finding,
named by 4 of 8 seats.
8. **Each spec citation says what it cites, and each bit mask has a name.** `wire/fields.ts` names
every `esm_class`, `data_coding` and `registered_delivery` field, with one line each. Every
`SMPP 3.4 x.y.z` cite carries a clause saying what that section requires. The terms are defined in
`docs/glossary.md`. This answers the juniors' Self-sufficiency 5, whose cost was citations with no
summary and bare masks rather than a missing glossary.
9. **Defaults live in one table.** `src/defaults.ts` holds every default and every internal cap, with
one line of why each (lesson 4).
## 2. Layout
Imports point one way: `wire ← text ← receipts ← link ← session ← client, server`. `bounded-store`,
`result`, `log` and `uuid` are leaves that any area may import. `link` never imports `session`.
Instead it reports to its owner through `LinkOwner`, a typed interface of five callbacks declared in
`link/link.ts`.
Fan-out: the root has 13 entries (6 leaf files and 7 areas). Each area has 3–11 files. The tree is
at most two levels deep below `src/`.
```
src/
index.ts Public surface, named exports only. No state.
defaults.ts Every default and internal cap (client, server, session, stores), one line of why each. No state.
result.ts Result<T>, VoidResult, errorFrom(): a thrown value turned into a result. No state.
log.ts SmppLog, silentLog, guardedLog. No state.
uuid.ts uuidv7(). State: the monotonic counter within one millisecond.
bounded-store.ts BoundedStore<V>. State: entries, total weight, sweep timer. Invariants: count <= max and weight <= maxWeight
after every write; expired entries are gone before admit() decides; the entry being written is never
dropped; every drop goes through onDrop exactly once.
wire/ The codec: bytes <-> PduObject. Stateless apart from PduFramer. (11 files)
commands.ts The 33 commands, their ids and params in WIRE ORDER (never sorted).
constants.ts consts + constsById, the interface versions.
fields.ts Named bit fields of esm_class, data_coding and registered_delivery, one line of meaning each.
Replaces hasUdh/messageTypeOf/messageClassOf's inline masks.
errors.ts ESME_* status table, ordered by id.
tlvs.ts TLV table, ordered by id; typed read/input shapes.
tlv-stream.ts Reading and writing a TLV stream. Split out of tlvs.ts.
types.ts Integer and C-Octet String wire types.
array-types.ts dest_address and unsuccess_sme arrays. Split out of types.ts (684 lines).
pdu.ts pduToObj / objToPdu / pduReturn / isCommand / isResp.
refusal.ts PduHeader, PduRefusedError, the status SMPP names for an unreadable PDU, maxPduLength.
framer.ts PduFramer. State: the partial-PDU buffer. Invariant: yields only whole PDUs; one bad length poisons the stream.
defs.ts `defs`: every table as one group.
text/ Message bodies: alphabets, splitting, concatenation, times. Stateless. (9 files)
gsm7.ts GSM 03.38 codec, named for what it is. The public EncodingName 'ASCII' maps to it in one line in alphabets.ts.
latin1.ts, ucs2.ts One codec each.
alphabets.ts detect, unencodable, encodingByDataCoding, dataCodingByEncoding.
split.ts splitMessage, bitCount, the 153-septet/134-octet segment budget (see §5).
udh.ts UDH length and concat fields; ConcatReference. State: the 8-bit reference counter.
concat.ts concatOf(): UDH or sar_*, which spelling.
body.ts messageOctets, decodeMessage, decodeSegments: where a body is and how it reads.
smpp-time.ts smppDate, smppTime encode/decode.
receipts/ Delivery reports. (4 files)
receipt-text.ts parseReceipt, receiptCodes, the researched stat: aliases (FAILED, DELIVERD).
dlr.ts dlrFromPdu: what marks a receipt, final vs intermediate.
sms-id.ts Id notations, <base>-<n> segment ids, which response carries an id.
merger.ts DlrMerger. State: expected groups plus the `spent` bases (two BoundedStores). Invariant: a base
merges at most once; an intermediate report never fills a slot. `close()` is renamed `spend()`.
link/ One socket's life. Everything here dies with the socket. (9 files)
link.ts Link and LinkOwner. State: phase (binding|bound|gone), gone-reason. Composes the files below.
Invariant: the phase only moves forward, and owner.onGone fires exactly once.
transport.ts Socket plus framer. State: none beyond the socket. The only write path to the socket.
timers.ts enquire_link heartbeat and idle timeout. State: two timers. Cleared when the phase is gone.
pending.ts Sequence numbers and correlation. State: next seqNr, seqNr -> waiter. Invariant: every waiter settles
once (answered, timed out, aborted, or unanswered when the link goes).
bind.ts The bind record {as, peerVersion}, checkedBind, bindCarries, standsInFor. State: the record. Set once.
answers.ts The answer ledger. State: owed requests by seqNr. Invariant: every inbound request gets exactly one
response on this link, or none because the link went; a second answer returns err.
inbound.ts Routes each inbound request to its one answer: onRequest hook, bind direction, enquire_link, unbind,
submit/deliver/data_sm, unknown commands. Stateless apart from what it calls.
handlers.ts Running onSms handlers. State: running set (a BoundedStore, 'refuse' policy), the refusing
hysteresis flag. Invariant: at most maxHeldMessages/maxHeldOctets run at once; the drain waits for zero.
reassembly.ts Reassembler. State: incomplete groups (a BoundedStore). Invariant: a segment is answered only once
it is kept; a group lost after answering is reported as lost traffic.
session/ What the application holds: one Session over a sequence of Links. (9 files)
session.ts Session. State: life, current link, last bound link, lifetime AbortController. Owns the transitions
open -> closing -> ended, and the link handover. Public events are emitted here and nowhere else.
link-wait.ts LinkWait. State: sends waiting for a bound link. Invariant: each settles on the next bound link, its
own deadline or signal, or lifetime abort.
window.ts SendWindow (maxOutstanding). State: in-flight count, FIFO queue. Invariant: in-flight <= max.
outbound.ts The one request path: admit -> wait for a link -> slot -> write -> answer; retry only if unwritten.
UnansweredError. No state of its own.
reconnect.ts Backoff. State: delay, upAt, timer. Stopped only through the lifetime signal.
shutdown.ts The drain order and budgets as one function over (handlers, window, lifetime). No state.
sms.ts The Sms handle given to onSms: fields, sendDlr(). No state beyond the message.
send-sms.ts submitSms composition, submitSmParams.
options.ts SessionOptions, ReconnectOptions, CloseOptions, SendOptions, checkSessionOptions.
client/ (3 files)
client.ts client(): compose, fromStart.
connect.ts Open a socket: TCP/TLS, connectTimeout, abort.
bind.ts The ESME's bind_* request and what a refusal means.
server/ (3 files)
server.ts server(), SmppServer. State: the listener and the live sessions set.
listen.ts Listener creation, TLS, startup errors.
accept-bind.ts authenticate, bind response, pre-bind refusals.
docs/glossary.md ESME, SMSC/MC, PDU, bind types, esm_class, data_coding, UDH, sar_*, TLV, message_state, receipt, segment, link, session.
```
## 3. Public API changes
These are the only changes. `client()` still resolves `{ err, session }`, `Session` keeps its events
(`close`, `disconnected`, `reconnected`, `dlr`, `messageDlr`, `sessionError`, `data`, `incomingPdu*`),
and the codec exports stay.
1. **`session.on('sms')` plus `sms.sendResp()` becomes an `onSms` option.** The option is accepted by
`client()`, `server()` and `new Session()`.
- Old: `session.on('sms', async sms => { await sms.sendResp({ smsId }); })`.
- New: `onSms: sms => ({ smsId })` (sync or async). Returning nothing answers `ESME_ROK` under a
generated id. `{ status }` refuses the message.
- A handler that throws or rejects refuses the message with the retry status: `ESME_RTHROTTLED` on
a submission, `ESME_RX_T_APPN` on a delivery. The error also goes to `sessionError`.
- With no `onSms`, the same retry status goes out at once. That needs a decision in
`docs/decisions.md` resting on goal 2 ("work the peer has no reason to send again is not dropped")
and goal 4. Today no listener means no answer, and the peer's own timeout decides.
- Reason: it removes the held-message timing contract (lessons 1–2) and the `captureRejections`
WeakMap. Goals 2 and 5.
2. **`sms.sendDlr()` before the message is answered returns `err`:** "report after onSms returns". The
answer is what gives the peer the id that the receipt names. Reason: without this rule there would
be a second answering path (F kept `sendResp()` for an early answer, which is two spellings of
answering). Goal 1.
3. **`session.sendReturn()` on a request that is already answered, or on a message whose `onSms` is
still running, returns `err`.** Today it writes a second response. Reason: principle 5, since the
ledger's invariant is also the public promise. Goals 1 and 4.
4. **`sms.answeredOnArrival` stays.** For a message whose segments were answered on arrival, a
returned `smsId` or refusing `status` goes to `sessionError`, where today `sendResp()` returns it as
an `err`.
Kept on purpose: the `Session` name, `boundAs`/`peerInterfaceVersion` surviving the reconnect gap (now
read from the last bound Link), the `sock` getter (the current or last Link's socket), and the
`reconnect.connect`/`onConnected` constructor options. F's rename to `SmppClient` did not move the
mean (6.25 = main), so its cost buys nothing a reader scores. MIGRATION.md and CHANGELOG.md record
changes 1–4.
## 4. Where each thing goes
| Now | New home |
| --- | --- |
| session.ts | session/session.ts (life, handover, events); the dispatch moves to link/inbound.ts, the refusal answer to link/answers.ts, the drain to session/shutdown.ts |
| link-life.ts | Split: the phase goes to Link.phase; waiting for a link goes to session/link-wait.ts; `stopped` and `retrying` go to Session.life plus lifetime; `generation()` is deleted, because a message holds its Link object |
| client.ts | client/client.ts, client/connect.ts, client/bind.ts; `bindOn`'s ordering comment is deleted (principle 3) |
| server.ts | server/server.ts, server/listen.ts, server/accept-bind.ts |
| incoming-requests.ts | link/inbound.ts (routing); throttled and refused-segment statuses go to link/handlers.ts and link/reassembly.ts; `reportLost` becomes LinkOwner.onLost |
| held-messages.ts, sms.ts (MessageHold) | link/handlers.ts (the running set and bound); session/sms.ts (the handle); answering goes to link/answers.ts |
| outgoing-requests.ts | session/outbound.ts; `requestPastDrain`/`requestOnCurrentLink` become one `request(input, { lane })`, with `lane: 'app' | 'receipt' | 'bind' | 'unbind'` and a single `admit(lane, life)` table |
| pending-requests.ts | link/pending.ts (per link, so linkLost is the Link going) |
| send-window.ts, idle-waiters.ts | session/window.ts; the idle wait is inlined there and in link/handlers.ts (the abort dance is copied, per the existing decision) |
| link-timers.ts | link/timers.ts |
| reconnect-loop.ts | session/reconnect.ts; `halted` is deleted in favour of the lifetime signal |
| pdu-transport.ts, pdu-framer.ts | link/transport.ts (no `attach`: one socket per Link), wire/framer.ts |
| pdu.ts, pdu-refusal.ts, retained-pdu.ts | wire/pdu.ts, wire/refusal.ts; `detach`/`retainedOctets` go to bounded-store's weigher callers in link/ |
| expiring-groups.ts | bounded-store.ts |
| reassembly.ts | link/reassembly.ts; `decodeSegments` goes to text/body.ts |
| dlr-merger.ts, dlr.ts, sms-id.ts | receipts/merger.ts, receipts/dlr.ts plus receipt-text.ts, receipts/sms-id.ts |
| message.ts, message-body.ts, udh.ts, concat.ts | text/split.ts plus smpp-time.ts, text/body.ts, text/udh.ts, text/concat.ts |
| send-sms.ts | session/send-sms.ts |
| session-options.ts | session/options.ts; `defaults` goes to defaults.ts; bind helpers go to link/bind.ts |
| error-from.ts, unanswered-error.ts | result.ts, session/outbound.ts |
| log.ts, result.ts, uuid.ts | unchanged at the root |
| defs/* | wire/*, with defs/encodings.ts split into text/gsm7.ts, latin1.ts, ucs2.ts and alphabets.ts, and masks into wire/fields.ts |
The responsibilities the panels named hard:
- **Held messages and answering:** link/answers.ts owns the one answer; link/handlers.ts owns the
running handler and its bound; link/inbound.ts calls the two in sequence.
- **Lifecycle and reconnect:** session/session.ts (life plus handover), link/link.ts (phase),
session/reconnect.ts (backoff).
- **The drain:** session/shutdown.ts.
- **Outgoing requests and retry:** session/outbound.ts (one loop), link/pending.ts (correlation),
session/window.ts, session/link-wait.ts.
- **Reassembly and ExpiringGroups:** link/reassembly.ts on bounded-store.ts.
- **Receipts and merging:** receipts/.
- **Codec:** wire/.
- **Encodings:** text/.
- **Defaults:** defaults.ts.
- **Domain knowledge:** docs/glossary.md plus wire/fields.ts.
## 5. The hard parts that stay hard
Each hard part has an `Invariant:` paragraph above the class or function that owns it. AGENTS.md
gains a "Hard parts" index of one line per entry, giving the file and the invariant.
1. **Exactly one answer, with multipart answered on arrival** (link/answers.ts, inbound.ts). A relaying
SMSC waits for each segment's answer, so segments are answered before the whole message exists, and
the handler's answer then has nothing left to write. All of it is in two adjacent files; the ledger
refuses the second answer loudly.
2. **Retry only what was never written** (session/outbound.ts). This is goal 2's "never re-send what
the peer may have taken". The loop: `boundLink()`, then a slot, then `link.write()`. An `unwritten`
result from a gone Link loops back to `boundLink()`. A written request that fails is
`UnansweredError`. The loop reads no link predicates; the Link's own result says what happened.
3. **The drain** (session/shutdown.ts): handlers first (answering can emit a receipt), then the window,
under one deadline. `shutdownTimeout: 0` never makes the handler wait forever. It is one function,
with the order and each budget commented once.
4. **The link handover** (session/session.ts `adopt(link)` / `onGone(link, reason)`). This is the only
place where `current` changes. The link-wait releases on `adopt`; `disconnected` or `close` is
emitted last.
5. **Backoff reset only after a link has outlasted `maxDelay`** (session/reconnect.ts). The existing
decision is linked from there.
6. **Bounded stores under pressure** (bounded-store.ts, and each owner's drop policy). Reassembly now
refuses a segment that would force its own group out, where today it evicts that group; the peer
keeps and retries it. The eviction of older groups is reported as lost traffic through onDrop.
7. **The segment budget** (text/split.ts): 153 GSM septets unpacked versus 134 octets, as AGENTS.md
explains.
8. **data_coding and esm_class** (wire/fields.ts): coding groups and message classes, named and
glossed.
## 6. Build order
Each chunk is green on its own and is one PR.
1. **Words first, with no behaviour change.** docs/glossary.md, a citation sweep, wire/fields.ts,
defaults.ts, and `gsm7`/`spend` renames. Re-run the panel cheaply on this alone to learn how much
Self-sufficiency moves.
2. **BoundedStore replaces ExpiringGroups.** Port Reassembler, DlrMerger and HeldMessages to it; tests
for the own-entry refusal.
3. **Mechanical move into wire/, text/, receipts/.** Imports only, plus the types.ts and tlvs.ts
splits.
4. **The contract.** `onSms` option, link/answers.ts, link/handlers.ts, session/sms.ts; `sms` event and
`sendResp()` removed; tests and README examples ported (draft-f's `port-session-test.py` and API map
help here); the no-handler decision recorded.
5. **Link.** link/link.ts owns the per-socket state (transport, timers, pending, bind, answers,
handlers, reassembly); Session gets `life`, `current`, `lifetime`, `adopt`/`onGone`; LinkLife is
deleted; reconnect.ts reads the signal.
6. **One request path.** session/outbound.ts with lanes, link-wait.ts, shutdown.ts.
7. **Client and server folders**, then the docs pass: README (Receive SMS, Server, Shutdown), MIGRATION,
CHANGELOG, decisions (retire LinkLife-era entries, add the onSms and ledger decisions), and the
AGENTS.md architecture and hard-parts index.
8. **Panel.**
## 7. Predicted panel risks
- **Session versus Link vocabulary.** A junior may not see why the public thing is a "session" and the
inner one a "link". The glossary defines both, and link/link.ts's invariant paragraph says "one
socket; a Session outlives many". It could still cost a Navigation point.
- **session.ts stays the biggest hub.** It composes link-wait, window, reconnect, merger and the
handover, so readers will rank it hardest. The mitigation is that it holds three fields and two
transition methods; the target is under 250 lines.
- **LinkOwner is a callback interface.** E's lesson was that meaning split across callbacks in another
file is hard. The mitigation is five callbacks, each with one line in link.ts, all implemented
side by side in session.ts. A seat may still call it indirection.
- **Lanes in outbound.ts.** Four lanes are still four rules, but they are in one table; round 2 cited
lanes spread over methods.
- **The sendDlr rule is a friction point** for test-double servers that want to report immediately.
The README must show the pattern (report after the handler returns), or seniors will call it a trap.
- **The public name 'ASCII'** still says ASCII for GSM 03.38. Renaming it is a breaking change this
plan does not take; alphabets.ts carries the one-line mapping.
- **The new own-entry refusal policy** is a behaviour change under pressure. It needs a test and a
decision entry, or the architect seat will flag it as unreasoned.
- **The scale is coarse.** Chunks 1–3 may lift juniors' Self-sufficiency to 6 and nothing else. The
full point needs chunks 4–5 to lift Locality to 6 in every seat, and to 7 in two.
-283
View File
@@ -1,283 +0,0 @@
# Plan 3: the newcomer's lens
A reader who has never opened the SMPP spec reads `protocol/` and learns the protocol from plain-English
types; a reader who knows it reads `session/` and holds the whole machinery at once. Builds on F (one
socket per `Session`, reconnect above it, `onSms` answered on return) and removes what F's panel still
named: `ExpiringGroups`, "exactly one answer" split over two files, and the junior's missing SMPP.
## 1. Principles
1. **Every octet that packs several facts is translated once, in `protocol/`, into a named plain type.**
`esm_class`, `data_coding`, `registered_delivery`, the UDH, `sar_*` and `message_state` are read and
written there and nowhere else; `session/` and `messages/` never see a bit mask. Answers: juniors at
Self-sufficiency 5 (masks, bare citations), "no glossary".
2. **The vocabulary is code.** `protocol/vocabulary.ts` holds one type per SMPP term (ESME/SMSC,
bind type, alphabet, message kind, segment, TLV, status), each with a one-line TSDoc definition the
editor shows on hover. A README glossary did not lift juniors; a definition beside the use does.
3. **A spec citation always carries its sentence.** `SMPP 3.4 §5.2.12 (esm_class): bits 5-2 say whether
this is a message or a receipt.` A test greps `src/` and fails on a bare `§x.y.z`. Answers: "citations
with no summary".
4. **A `Session` is one socket, and its life only moves forward.** `open → bound → closing → closed`,
one field, one transition function, no flag beside it. Reconnect lives above, in `SmppClient`, which
holds only what outlives a socket. Answers lesson 3 (LinkLife, seven predicates, call-order
correctness, duplicated stopped flags); F showed it takes the lifecycle off the hardest-unit list.
5. **An answer is a return value, and one function writes it.** Every inbound request resolves to one
`Reply`; `session.ts` writes it in one place. `onSms` and `onRequest` return replies instead of
calling a sender. Answers F's "exactly one answer enforced jointly by IncomingRequests and sms.ts",
and lesson 4's callbacks into `Session`.
6. **A bounded store refuses; it never evicts.** One `BoundedStore` enforces its own count, weight and
expiry; reads never mutate; the only removal a caller did not ask for is expiry, reported through
one callback. A full store refuses the newcomer, and the peer retries it (goal 2: nothing the peer
will not resend is dropped). Answers `ExpiringGroups`/`Reassembler.trim` (4 of 8 F seats).
7. **Every default is one row in one table.** `options.ts`. Answers "defaults spread over several files".
8. **Names say the domain, not the implementation.** GSM 7-bit is `gsm7` everywhere; `DlrMerger.close`
becomes `spend`; no `ascii`.
## 2. Layout
Areas, in reading order: `protocol/` (what SMPP means), `codec/` (bytes ↔ objects), `messages/`
(whole messages), `session/` (one socket), `client/`, `server/`. Imports point down that list in
reverse: `codec` ← `protocol` ← `messages` ← `session` ← `client`/`server`; `protocol` knows `codec`'s
tables only. Fan-out: root 10 (4 files, 6 dirs); `codec/` 8; `protocol/` 10; `messages/` 8;
`session/` 7; `client/` 3; `server/` 2. No file over ~300 lines except `codec/field-types.ts`.
```
src/
index.ts Public surface, named exports only.
options.ts `defaults`: every default and internal cap, one row each with its why; the
option checks for client(), server() and new Session(). No state.
result.ts Result<T>, VoidResult, errorFrom(), namedValue(). No state.
log.ts SmppLog, silentLog, guardedLog(). No state.
codec/ Bytes <-> PduObject. Knows field layout, never meaning.
commands.ts The 33 commands, ids, params in wire order (invariant: never sorted).
tlvs.ts TLV table by id, typed read/input shapes, read/write a TLV stream (exact to
command_length, one NULL pad tolerated).
statuses.ts command_status table (ESME_*), by id.
constants.ts Raw numeric tables (`consts`), exported as-is; meaning lives in protocol/.
field-types.ts int8/16/32, C-Octet and Octet strings, buffers, address arrays; range-checked.
pdu.ts pduToObj / objToPdu / pduReturn / isCommand / isResp. Synchronous, total.
framer.ts PduFramer: a byte stream cut into PDUs; state: the chunk list.
refusal.ts PduRefusedError, framing refusal, the status a refused PDU is answered with.
protocol/ What the fields mean. Plain types out, SMPP octets in. No state.
vocabulary.ts One type per SMPP term with its one-line definition: LinkEnd (ESME/SMSC),
BindType, Alphabet, MessageKind, Segment, MessageState, Reply. The glossary.
alphabets.ts The gsm7, latin1 and ucs2 codecs; detect(); unencodable(); bitCount().
data-coding.ts data_coding as a table of rows {octets, alphabet, messageClass, why}; read
and write through the table, never a mask outside it. Flash lives here.
esm-class.ts esm_class -> {kind: message|receipt|intermediate, hasUdh, mode} and back.
segments.ts How a PDU says it is a segment: the UDH (walked, with its reference) or sar_*,
and the reference counter per client (state: one counter).
receipt.ts Receipt text and TLVs -> Dlr (stat: spellings, FAILED, DELIVERD, final or
not), and a receipt's deliver_sm params for a state.
message-ids.ts Id notations (smsIdFormat), <base>-<n> segment ids, which response carries one.
time.ts smppTime (absolute/relative) and the receipt's YYMMDDhhmm stamp.
bind.ts What a bind direction carries, data_sm's stand-in, the version that allows
optional params, checkedBind().
arrival.ts One inbound message-carrying PDU -> Arrival: {kind:'message', text, from, to,
segment?, wantsReceipt, flash} | {kind:'receipt', dlr}. Body location
(short_message vs message_payload) decided here.
messages/ Whole messages across segments and time.
split.ts Text -> segment bodies at the 153-septet / 134-octet budget (invariant: GSM is
sent unpacked; see AGENTS.md).
submit.ts SendSmsOptions checked, then one submit_sm params object per segment; all
refusals before any segment exists.
bounded-store.ts BoundedStore<T>: count cap, weight cap, per-entry deadline. add() returns
added|full; get() is pure; expiry on its own timer, reported via onExpire.
reassembly.ts Reassembler: segment groups in two reference spaces (udh, sar); add returns
kept|unplaceable|full|whole. State: one BoundedStore.
receipt-merge.ts ReceiptMerge: per-segment Dlrs -> one MessageDlr; a base merged once (spent).
State: two BoundedStores (open, spent).
retained.ts detach() a PDU off its chunk; retainedOctets() for weights.
session/ One socket's life. State lives in session.ts, requests-out.ts, handlers.ts.
session.ts Session: lifecycle field + transition table, the event surface, admit() for
sends, write() for replies — the only writer of responses. ~250 lines.
requests-out.ts OutgoingRequests: sequence numbers, pending map, send window, response
timeout, abort. One entry point. Reports written-or-not on failure.
requests-in.ts replyFor(pdu, context) -> {reply?, after?: 'close'}: bind direction,
enquire_link, unbind, receipts, messages, unknown commands. No state, no
callbacks into Session.
handlers.ts RunningHandlers: onSms calls in flight; count/weight bound; handlerTimeout
answers with the retry status; idle() for the drain.
keepalive.ts enquire_link on a quiet link, idle timeout. State: two timers.
transport.ts Socket -> framed, parsed PDUs; refusals; the socket is fixed for life.
waiting.ts waitUntil(predicate, budget, signal) and leftOf(): the abort dance, once.
client/
client.ts SmppClient: current session, cross-link state (ReceiptMerge, segment
reference counter), request loop that waits for a bound session and retries
only an unwritten request; re-emits session events; close()/unbind().
connect.ts openSocket (net/TLS, connectTimeout) and bind(); returns a bound Session.
backoff.ts Backoff: next delay, reset after a link outlasts maxDelay. No timers, no stop flag.
server/
server.ts SmppServer, server(): listener, live sessions, close() drains all.
accept-bind.ts authenticate, record the bind, bind_resp; before-bind refusals.
```
## 3. Public API changes
Six, all in the minor that ships this (pre-1.0: the minor is the breaking unit). Each lands in
MIGRATION.md; the goal it rests on is recorded in docs/decisions.md.
| # | Old | New | Removes | Goal |
|---|---|---|---|---|
| A1 | `client()` → `{ err, session }`, one `Session` reconnecting under you | `{ err, client }`, an `SmppClient` with `sendSms/send/close/unbind`, events `close/disconnected/reconnected/dlr/messageDlr/sessionError`; `client.session` is the current bound `Session` or `undefined` | Lifecycle as the hardest unit: LinkLife, 7 predicates, re-entrant close, two stopped flags, bindOn's cross-file ordering | 5, 8 |
| A2 | `session.on('sms', sms => sms.sendResp(opts))`; drain waits on unanswered `sms` | `onSms: sms => Reply \| void` option on `client()`, `server()`, `new Session()`; return answers (`ESME_ROK`, or `{ smsId }`, or `{ status }`); throw/reject answers the retry status and reports on `sessionError`; no `onSms` answers the retry status; `sendResp()` removed | Held-message timing contract (six exits, setImmediate, listener counts, WeakMap) and "answered in three places" | 2, 5 |
| A3 | `await sms.sendResp(); await sms.sendDlr()` in the listener | `onSms` may return `{ dlr: MessageState }`: the receipt goes out right after the answer; `sms.sendDlr()` stays for later reports and returns `err` before the answer is written | The "sendDlr let past the drain straight after sendResp" rule; a receipt can no longer precede its answer, and awaiting one inside the handler cannot deadlock | 2 |
| A4 | `onRequest: (s, pdu) => boolean`, answering via `session.sendReturn()` | `onRequest: (s, pdu) => Reply \| undefined`; `undefined` = built-in handling; `sendReturn()` removed from `Session` (`pduReturn()` stays in the codec) | The second answering path; makes "one answer per request" a type | 2, 7 |
| A5 | `new Session({ reconnect, ... })`, mutable `session.linkEnd` | no `reconnect`; `linkEnd` option, readonly; `disconnected`/`reconnected`/`messageDlr` only on `SmppClient` | Reconnect state inside the one-socket unit | 8 |
| A6 | `encoding: 'ASCII'` | `encoding: 'GSM7'`; `'ASCII'` refused at option check with "use 'GSM7'" | A junior reading "ASCII" for GSM 03.38 | 1 (a wrong name invites wrong data), 8 |
Unchanged on purpose: the codec exports, `sendSms()` options and result, `Dlr`/`MessageDlr`, `server()`
options, `sessionError`/`serverError`, `PduRefusedError`. Behaviour change without a signature change:
the reassembly and receipt-merge stores refuse at their bound instead of evicting the oldest (segment:
`ESME_RTHROTTLED`/`ESME_RX_T_APPN`; merge: that send reports through `dlr` alone). Recorded as a
decision on goal 2, which it serves better than eviction did (an evicted group is answered traffic lost).
A6 is the cheapest to drop if the board wants fewer breaks; the internal rename happens either way.
## 4. Where each thing goes
| Now | New home |
|---|---|
| index.ts | index.ts |
| client.ts | client/connect.ts (socket, TLS, timeout, bind), client/client.ts (fromStart, abort) |
| server.ts | server/server.ts, server/accept-bind.ts |
| session.ts | session/session.ts (lifecycle, events, write), client/client.ts (reconnect parts) |
| link-life.ts | deleted: lifecycle field in session/session.ts; "wait for a link" in client/client.ts |
| reconnect-loop.ts | client/backoff.ts (delay math) + client/client.ts (the one timer, stop = state) |
| link-timers.ts | session/keepalive.ts |
| pdu-transport.ts | session/transport.ts (no attach(): one socket) |
| outgoing-requests.ts, pending-requests.ts, send-window.ts, unanswered-error.ts | session/requests-out.ts (one entry point; UnansweredError kept, exported type unchanged) |
| incoming-requests.ts | session/requests-in.ts (returns replies) |
| held-messages.ts | deleted: session/handlers.ts counts running handlers |
| idle-waiters.ts | session/waiting.ts |
| sms.ts | session/handlers.ts builds the Sms; its fields come from protocol/arrival.ts; sendDlr params from protocol/receipt.ts |
| expiring-groups.ts | messages/bounded-store.ts |
| reassembly.ts | messages/reassembly.ts |
| dlr-merger.ts | messages/receipt-merge.ts (close → spend) |
| dlr.ts | protocol/receipt.ts |
| concat.ts, udh.ts | protocol/segments.ts |
| message-body.ts | protocol/arrival.ts |
| message.ts | messages/split.ts (split, budgets), protocol/alphabets.ts (encode/decode/bitCount), protocol/time.ts |
| send-sms.ts | messages/submit.ts |
| sms-id.ts | protocol/message-ids.ts |
| session-options.ts | options.ts (defaults, checks), protocol/bind.ts (directions, stand-in), session/session.ts (events type) |
| retained-pdu.ts | messages/retained.ts |
| pdu.ts, pdu-framer.ts, pdu-refusal.ts | codec/pdu.ts, codec/framer.ts, codec/refusal.ts |
| defs/commands, tlvs, errors, types, constants | codec/commands, tlvs, statuses, field-types, constants |
| defs/encodings.ts | protocol/alphabets.ts + protocol/data-coding.ts |
| defs/index.ts | deleted; `defs` assembled in index.ts |
| error-from.ts, result.ts | result.ts |
| log.ts, uuid.ts | log.ts; uuid.ts → protocol/message-ids.ts |
| Hard responsibility | Home |
|---|---|
| Held messages and answering | session/requests-in.ts decides the reply; session/handlers.ts runs onSms and turns its outcome into a Reply; session/session.ts write() is the one writer |
| Lifecycle | session/session.ts: 4 states, forward only, `close` emitted on entering `closed` |
| Reconnect | client/client.ts (loop, current session), client/backoff.ts (delays) |
| The drain | session/session.ts `close()`: state → closing, `handlers.idle(budget)`, then `requests.idle(rest)`, then closed |
| Outgoing requests and retry | session/requests-out.ts (one link, no retry); client/client.ts (retry on the next link only when unwritten) |
| Reassembly, store | messages/reassembly.ts over messages/bounded-store.ts |
| Receipts and merging | protocol/receipt.ts (reading/writing), messages/receipt-merge.ts (merging), client/client.ts (owns the merge across links) |
| Codec | codec/ |
| Encodings | protocol/alphabets.ts, protocol/data-coding.ts |
| Defaults | options.ts |
| Domain knowledge, glossary | protocol/vocabulary.ts and the rest of protocol/ |
## 5. The hard parts that stay hard
Each is marked by one invariant paragraph at the top of the function it guards (the one comment
exception AGENTS allows), and named in AGENTS.md's architecture list as "hard".
1. **Multipart is answered on arrival, a whole message on return** (decision: a relaying SMSC waits per
segment). One function, `requests-in.ts replyFor()`, holds both branches; `Sms.answeredOnArrival`
stays; a Reply refusing an arrival-answered message goes to `sessionError`.
2. **Segment grouping**: two reference spaces, inconsistent totals, the refusal status per spelling.
`messages/reassembly.ts` only; the store underneath is dumb.
3. **The drain's two budgets** (handlers ignore `shutdownTimeout: 0`, requests do not).
`session.ts close()`, one function, both budgets computed in one place from `options.ts`.
4. **Retry only what never reached the socket** (goal 2). `client.ts request()`, one loop, reading only
`result.written`; no link state consulted mid-await because the session it used is fixed.
5. **data_coding**: coding groups, class bits, 0x01 read as GSM. Becomes a readable table in
`protocol/data-coding.ts`, with a test that the table equals today's function on all 256 octets.
6. **Codec strictness**: TLV stream exact to `command_length`, one NULL pad, 32-bit seqNr echo.
`codec/pdu.ts` and `codec/tlvs.ts`.
7. **`fromStart` + abort**: `client/client.ts client()` only; the loop's stop is the client's state
`closed`, so no ordering promise crosses files.
## 6. Build order
The order that runs is todo.md's, amended by §8.
## 7. Predicted panel risks
- **Two send surfaces.** `SmppClient.sendSms()` and `Session.sendSms()` look alike; a reader asks
which to call. Mitigation: the Session's is the one-link primitive, documented as such; still likely
a Navigation point.
- **`Reply` carrying `dlr`** is a second way to send a receipt beside `sms.sendDlr()`. Different
results (with the answer vs later), but a strict reader may call it two spellings.
- **Refuse-not-evict**: a peer that abandons many groups blocks new multipart for `reassemblyTimeout`.
A senior may call that an operator-facing regression (goal 4) and score Shape down.
- **protocol/ vs requests-in.ts**: "is it a receipt" (arrival.ts) and "what do we answer"
(requests-in.ts) are split by design; a junior may look for both in one place.
- **codec/field-types.ts** stays ~650 dense lines; it was never the named unit, but a junior reading
it cold still scores Self-sufficiency down unless its citations carry their sentences too.
- **Test suite size**: porting ~all session tests is the real cost; a half-ported suite hides
regressions that a panel will not see but goal 1 will.
- **The coarse scale**: even if every named unit is fixed, a mean of 7.0 needs all four seats to move,
and the hardest-unit list has moved every round; expect a new one (likely requests-in.ts or
SmppClient's request loop) at 6.
## 8. Architecture review, 2026-09-30
Verdict ALIGN: the direction serves the goals, but §6 could not leave the suite green and the plan
overturned recorded decisions without naming them. todo.md carries the reordered build; the rest:
1. **Each public API row names the decision it replaces**, and the replacement lands in
docs/decisions.md in the chunk that makes it. Overturned without saying so: `src/` stays flat;
`Session` publicly constructible (`ReconnectOptions` moves); both emitters re-declare listeners
(`SmppClient` is a third); every segment answered on arrival (a refused `smsId` becomes a
`sessionError`); `server()` composes `onRequest` (A4 is its rejected alternative); the drain waits
on held messages, capped on constants; `linkEnd` beside `boundAs` (A5 makes it an option); bind state
holds through the gap, and one owner decides whether a link carries a request; `close` means over;
a receipt does not belong to the link; a base merged once, capped like the groups; the total
encoding signatures and GSM declaring 0x00 (A6 renames `EncodingName`, so the codec exports do
change); the abort dance copied, not extracted (`waiting.ts`: recount the sites, then keep the
copies or revise the decision).
2. **The segment reference counter is `SmppClient` state**, passed to `messages/submit.ts`; `protocol/`
holds none.
3. **`client/next-link.ts`** bounds the wait by `responseTimeout` from when the send was issued,
builds the PDU against the session it lands on (retiring "bind state holds through the gap"), fails
waiting requests as unwritten on `close()`/`unbind()`, and registers a multipart send's merge before
its segments go out.
4. **`SmppClient` re-emits every `Session` event but `sms` and `close`**; a session's `close` is
`disconnected` unless the client is over. `client.session` is the current link, and a listener on it
lasts one link.
5. **`sms.sendDlr()` goes through a receipt sender `handlers.ts` is given**: `SmppClient`'s next-link
path in client mode. The answer stays on the arrival session, which `Sms.session` names.
6. **One function in `session.ts` owns the async answer**: `replyFor()`, then `handlers.run(sms)` where
the application decides, then `write()`. `handlers.ts` returns a `Promise<Reply>` and never calls
into `Session`.
7. **`BoundedStore` is internal**, not goal 9's store interface; `ReceiptMerge` records stay plain data.
Reassembly refuses at its bound: an evicted group is answered segments lost, goal 2 outranks goal 4,
and the store is per session. The spent set expires by age.
8. **`retained.ts` goes to `codec/`**; `options.ts` joins AGENTS.md's type-only ways back up.
9. **A file keeps its export's name until the chunk that renames the export.** The scoring run of
2026-09-30 read §2's names on unrenamed classes (`keepalive.ts` holding `LinkTimers`) as lies, so
§2 and §4 name where a file ends, not what it is called before its export changes.
Public API questions, answered as the review recommends. Maintainer's call, 2026-09-30; each
lands in docs/decisions.md with the chunk that builds it:
- **Q1 (A1).** `client()` returns `{ err, client }`? Yes: the returned type changes anyway.
- **Q2 (A3).** `onSms` may return a receipt state? Yes, against the board: without it "answer, then
report at once" has no correct spelling, and the two differ in result, so they are not two spellings.
- **Q3 (A4).** `onRequest`'s `Reply`: any status, `params` and `tlvs`, or an explicit no-answer, async
allowed. Narrower cannot answer a bind, a vendor command or a `data_sm`.
- **Q4 (A6).** `'ASCII'` becomes `'GSM7'` in every export? Yes: dropping it keeps two names for one
alphabet at the boundary.
- **Q5.** `sendSms()` and `messageDlr` on `SmppClient` only, `Session` keeping `send()`? Yes: one send
surface and one counter; a hand-wired `Session` loses `sendSms()`.
- **Q6.** No `onSms` answers the retry status, with a warning once per session? Yes, goal 2 over goal 4.
- **Q7.** `handlerTimeout` a constant, answering the retry status on expiry, a late `Reply` on
`sessionError`, the drain waiting at most that long? Yes.
- **Q8.** A full merge store: register before sending and say in `SendSmsResult` that no merged report
follows? Yes, over oldest-eviction for merges or a silent refusal.
-825
View File
@@ -1,825 +0,0 @@
# Decisions
The standing decisions for `@larvit/smpp`, grouped by what each one constrains. A decision is
written down only when it cannot be put better as a goal — [AGENTS.md](../AGENTS.md) carries that
rule and an index of the titles below.
## The public surface
- **`Session` is publicly constructible, which is what makes `SessionOptions` and `ReconnectOptions`
public too.** Raised twice as a leak; it is not one. The collaborators `session.ts` delegates to
stay unpublished so they can be reshaped.
- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own
senders consult them; `session.send({ tlvs })` is passed through as written, because silently
stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than
sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the
three the library dispatches by it, of which it sends the first two.
- **Both emitters re-declare their listener methods to accept a promise.** Maintainer's call,
2026-08-27: `EventEmitter` types every listener as void-returning, so the
`session.on('sms', async sms => …)` README documents reads as a misused promise in any strict
consumer. `declare on: …` and its six siblings re-type the inherited methods to return `unknown`,
which emits nothing and needs no cast; overriding them as real methods cannot work, because the
`super.on()` call needs one. The cost is that a subclass can no longer reach those seven through
`super` — re-declaring them the same way is its way out. `unknown` rather than
`void | Promise<void>` because a listener may return anything: `session.on('close', () =>
set.delete(session))` returns a boolean. This also settles what the drain can wait on: a listener's
own promise would be the better completion signal, and reaching it needs `listeners()`, which
cannot be re-declared the same way — Node types it invariantly enough that widening `void` to
`unknown` is `TS2416`. Re-probed 2026-09-01; `sendResp()` stays the signal.
- **`PduRefusedError` is exported, and `sessionError` names it in the event's type.** Maintainer's
call, 2026-09-05, from a product review: one event carries both a PDU the peer malformed and the
session's own failure, and `instanceof` is the only way to separate them that hard rule 4 allows —
without the class as a value an application is left string-matching `err.message`. Goal 8 is paid by
exporting the discriminant and the struct it carries and nothing else: `PduHeader` is named because
an application that logs or forwards a header wants a name for it, `PduRefusalReason` is not
because `reason` is compared against string literals, and an accessor
(`PduRefusedError['header']`) names either one where a signature wants it. The payload union
enforces nothing — a subclass narrows out of `Error` either way — and is there so the event's own
type names what to narrow to, which is also what makes it a half-truth if a second `Error` subclass
ever reaches this event without joining it. Rejected: a `SessionError` alias for that union, a
third name for a type that is structurally `Error`. Rejected: a separate `pduRefused` event, which
splits the failure channel so an application that wants every failure listens twice and an existing
listener silently stops seeing refusals. Rejected: coalescing or rate-limiting them, which re-opens
the standing decision that `sessionError` carries every failure, never coalesced or suppressed —
the filtering belongs where the application is, since only it knows which peer is routinely sloppy.
Rejected: an error code on a plain `Error`, which reads back off an `unknown` property only through
a cast and types nothing it carries. Accepted: a second copy of the package installed alongside
this one defeats `instanceof`, where `err.name` still reads `PduRefusedError`.
- **`bitCount()`, `encodeMessage()` and `splitMessage()` keep their total signatures, because
`EncodingName` is what keeps an alphabet with no codec away from them.** Maintainer's call,
2026-09-09, from the architecture review of [#95](https://github.com/larvit/larvitsmpp/pull/95):
all three index `encodings` by name and would throw on one it has no codec for, which hard rule 1
forbids. That PR left no such name to pass — `encodings` is a `Record<EncodingName, Encoding>`, so
every member of the union has a codec and one added without a codec, or without a segment budget,
fails to compile in four places. What was missing is the door for a caller holding a name at
runtime: `Object.hasOwn(encodings, x)` is the only test the published surface offered and it
narrows nothing, so `isEncodingName()` is exported beside `isCommandName()` and `isErrorName()`,
which serve their own tables that way. Rejected: a `Result` signature on all three, which costs
every typed consumer a narrow forever — goal 8, and the tag is the last cheap chance to spend it —
to guard a state the compiler refuses. Where the domain really is open the check is already there:
`sendSms()` widens `encoding`, `messagingMode` and the two time options to `unknown` and refuses
each by name, which is what a caller without types gets. `smppTime.encode()` is where that reasoning lands the other way and is recorded
under [The wire](#the-wire): `Date | number | string` is not a closed set, so it is a `Result`.
- **A segment the SMSC took and named no id for is `undefined` in `smsIds`, not an empty string.**
Maintainer's call, 2026-09-12: `paramText()` resolves an absent `message_id` and one a peer wrote
empty to the same `''`, which `string[]` then presented as an id — taking `smsIds[0]`, or keying a
correlation table by the array, compiled and then misbehaved, and one message's empty entry
collides with another's. Telesign names an id for the first segment of a concatenated submit only,
so it is a documented operator's shape rather than a hypothesis. Nothing else moves:
`parseSegmentId('')` matched nothing, so `DlrMerger` already abandoned such a send and `undefined`
reaches that same refusal. `expect()` takes the wider type rather than a filtered `string[]`
because the arity is what `idNumbering()` refuses on: filtering `['a-1', undefined, 'a-3']` leaves
a numbering that spells out a whole message, and merges one that was never whole. `dlrFromPdu()`
reads an id through `nonEmptyText()`, so no receipt could ever have matched an empty entry — and
that reading stays separate from this one rather than sharing a helper, since it must leave a
Buffer-valued `receipted_message_id` unresolved for `messageType()` to read the PDU as unmarked.
What settles it here is the resolved text rather than the parameter, because `writeParams()`
substitutes the field's own default: a peer that omits `message_id` and one that writes it empty
build the same octets, leaving a raw-parameter test nothing to tell apart. Rejected: keeping `''`
and documenting it, which leaves the published type promising what the value does not keep — goal
2, a wrong answer about what the peer named. Rejected: dropping the unnamed entries, which breaks
the positional correspondence with `pduObjs` that README promises and loses which segment a PDU
belongs to. Rejected: `{ id?: string; pduObj: PduObject }[]`, which makes that positional promise
structural where today the compiler cannot check it; deferred to the next breaking release, the
first place two documented fields may become one. Accepted: every consumer reading `smsIds`
narrows, including the majority whose SMSC names every id; indexing narrows too, except for the
consumer who sets `noUncheckedIndexedAccess`, which typed `smsIds[0]` as `string | undefined`
already.
## The wire
- **The declared interface version is an option on both `client()` and `server()`, and is not the
optional-parameter threshold.** That threshold is fixed at 0x34 by the spec, so an implementation
that must declare 5.0 throughout can, without moving it.
- **A peer that declared no version is pre-3.4, and `undefined` means no bind yet.** `bound()`
records what the peer declared, the ESME's `interface_version` or the SMSC's
`sc_interface_version`; a peer that declared nothing is recorded as `undeclaredInterfaceVersion` (0x00)
and sent no optional parameters, which is how the spec reads an absent `sc_interface_version`.
- **`esm_class` decides what a `deliver_sm` is, and the body is read only when it names nothing.**
The two types the MC writes about a message we submitted — `MC_DELIVERY_RECEIPT` (0x04) and
`INTERMEDIATE_DELIVERY` (0x20) — are reports whatever the body parses to, so one in a format
`dlrFromPdu()` cannot read reaches `dlr` with `smsId` undefined instead of arriving as an inbound
SMS. The three the far-end SME writes (0x08, 0x10, 0x18) are messages and their bodies are not
scraped: Kannel reads 0x08 as report-bearing and this does not, because a delivery acknowledgement
is the handset's word about a message, not the network's. A message type of 0 or one of the ten
reserved keeps the scrape, and a non-empty `receipted_message_id` TLV marks a report on the same
footing. A report this library recognises never reaches the reassembler, so an SMSC that splits one
across segments gets a `dlr` per segment rather than one merged report. The `message_state` TLV is
authoritative only where it names a state in the table — SMPP reserves 0x80-0xFF for
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
the body.
- **A body is read from `message_payload` where `short_message` carries none, and `short_message`
wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability
phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot
carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone
handed the application an empty message
([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). `messageOctets()` is
the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()`
cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever
it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the
spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the
rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the
TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both,
which discards a message that is almost certainly present twice over, where goal 3 keeps the
traffic.
- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none,
and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from
the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make
`sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says,
Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed
the application one `sms` per fragment
([interop-tests/findings/05-java-clients.md](../interop-tests/findings/05-java-clients.md)).
`concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to
where a body is, and both are exported for the same reason: an application on the low-level
surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the
reference, so the key is two tokens the reassembler joins and interprets neither of, and so the
refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL`
for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would
assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num`
is 16 and neither counts the other's messages; the two UDH widths share a space because they are
one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names
the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps
the change additive for every message that reassembled before, and leaves the library nothing to
guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a
gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the
references are not comparable at all, and where the parts are, the UDH is still what the message
is assembled by, so the report would name a failure the application cannot act on. Accepted: a
peer that switches spelling mid-message now has two groups that expire rather than fragments that
arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()`
goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second
spelling of one message whose only difference is which peers accept it.
- **`sendSms()` takes the messaging mode by name, and it is the only part of `esm_class` a caller
writes.** Maintainer's call, 2026-09-06, closing target 5 of the interoperability plan: every peer
the suite ran took the 0x40 this library sends on a concatenated segment, but Route Mobile and
Kaleyra both document `esm_class` 0x43 for one, and a caller facing either had to hand-build every
segment through `send()` — giving up the split, the per-segment ids, the send window and the
receipt merge, which is what goal 8 means by beating "the application can do this itself". The four
modes of SMPP 3.4 5.2.12 are a `MESSAGING_MODE` constant group and the option takes one of their
names, so 0x43 is a composition this library makes rather than a value a caller states, and the UDH
indicator a segment carrying a header needs cannot be cleared by anything the option can express.
It takes three of those four: 2.10.3 carries transaction mode on `data_sm` alone, and none goes out
of here, so `FORWARD` stays in the group that mirrors the spec table and `sendSms()` refuses it by
that reason rather than as an unknown name — a mode this library cannot deliver is a promise goal 8
will not let it make. `DATAGRAM` with `dlr: true` is refused on the same footing: 2.10.2 defines the
report away, so arming `DlrMerger` for one is goal 2's wrong answer, where the mode alone and a
report under any other mode both go out untouched. Those three names left `ESM_CLASS`, where they
had `constsById.ESM_CLASS` read 0x03 as a whole `esm_class`. Rejected: a raw `esmClass` number,
which is exactly that clearable state and would need refusing bit by bit to be safe. Rejected:
taking a number beside a name, two spellings of one goal — which is why a value naming no mode is
refused, by name, before a segment goes out. Rejected: a session-level default with a per-send
override; an operator's requirement is a property of the link, but the library can verify nothing
the caller's own options object does not, and shipping both buys a precedence rule to document and
test for that. `SMSC_DEFAULT` is named so pinning the default deliberately is sayable.
- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase:
SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and
Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID`
dropped with nothing reported to the application at all. Every command but this one names its own
direction, which is why the bind gate and the dispatch never had to be told which end of the link
they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a
delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a
submission and is read as one, because a report about a message this end never sent is goal 2's
wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by
segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves
`deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs`
rather than a `SessionOptions` entry, so the code that knows which end this is writes it and
nothing else can contradict what the session then binds as. Rejected: grouping the command with
`deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with
`submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one —
`send()` reaches the command raw, and an option choosing which command a message goes out on would
be a second spelling of `sendSms()` whose only difference is which peers accept it.
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
echo, so decoding by that field turns an Appendix B receipt into UCS-2 garbage — total loss
against the many peers that send no TLVs to fall back on. `dlrFromPdu()` reads
`PduObject.shortMessageOctets` through Latin-1, the one codec that maps every octet to a
character, so the fixed fields parse whatever the PDU claims; the codec keeps both spellings
because a message needs the text and a receipt needs the octets. Rejected: honouring `data_coding`
where the octets yield no field, which reads one body two ways for the sake of a peer writing a
UCS-2 receipt body that no researched SMSC is — that peer's receipt yields no fields at all here,
which goal 2 reports as undetermined rather than guessed. An inbound message is untouched: nothing
but `data_coding` can say how a message was written.
- **A message class is read where GSM 03.38 puts it, `flash` is class 0 alone, and a flash message
with no alphabet to carry it is refused.** Maintainer's call, 2026-09-09, closing the last target
of the interoperability plan: `sms.flash` was `(data_coding & 0xF0) === 0x10`, which called the
ME-, SIM- and TE-specific classes immediate display and missed the 0xF0 group entirely — the only
one SMPP 3.4 5.2.19 names, since it marks 0x0F to 0xBF reserved and hands 0xF0 to 0xFF to GSM
03.38, and the one SMPPSim demonstrated
([interop-tests/findings/02-smppsim.md](../interop-tests/findings/02-smppsim.md), C17).
`messageClassOf()` is the single answer to whether a `data_coding` carries a class and which, as
`concatOf()` is to how a PDU says it is a segment: 03.38 section 4 puts the class in bits 1-0,
carried where bit 4 says so in every group below 0x80 and always in the 0xF0 group, and
`encodingByDataCoding()` reads the alphabet off that same test rather than repeating the group
masks beside it. It is exported for the reason `concatOf()` is — an application that needs a class
other than 0 would otherwise rewrite the read this fixed. Rejected: a `messageClass` field on the
`sms` event, which pays goal 8 for three classes nothing here acts on, where the boolean the
application already had covers the one it does. Compressed text is out of scope and stays out —
nothing here implements 3GPP TS 23.042, so a compressed body reaches the application as whatever
its declared alphabet makes of it — but bit 5 does not move the class bits, so 0x30 is read as
class 0 rather than special-cased into a wrong answer; 01xx is read for the same reason, 03.38
coding it exactly as 00xx. Rejected: reading only the two groups the defect named, which needs an
extra test to produce a wrong answer for a class the spec puts in plain sight. Accepted: the
alphabet is read only where a class is, so 0x58 is UCS2 while 0x48 — the same alphabet with the
class bit clear — stays ASCII, because below 0x10 SMPP's flat table contradicts 03.38 and wins
(0x03 is Latin-1 there, GSM 7-bit here) and a class is the only evidence a peer below 0x80 is
spelling 03.38 at all. Send-side: `flash`
is that class, so it goes out as 0x18 beside UCS2 and 0x10 beside GSM 7-bit, while
`encoding: 'LATIN1'` beside it is refused before a segment goes out, the way a messaging mode this
library cannot deliver is — 03.38's class groups hold GSM 7-bit, 8-bit data and UCS2, and Latin-1 is
SMPP's own flat-table alphabet, so the pair has no spelling. Rejected: 0x10 with Latin-1 octets,
which declares an alphabet the body is not in; rejected: 0x14, 8-bit data, which is not text to
the handset that would display it; rejected: promoting it to UCS2, which overrides the one option
the caller wrote in order to override a choice. Rejected with them: `encoding: 'FLASH'`, which
named `data_coding` 0x10 among the alphabets and so reached the class through the option that
chooses a charset — a second spelling of `flash: true` that also flattened every non-GSM character
to a space on the way. It leaves `EncodingName`, which is now exactly the three codecs `detect()`
and `encodingByDataCoding()` return, and `encoding` is checked by name like `messagingMode` so a
caller without types gets a refusal rather than a throw out of the codec table. `consts.ENCODING`
keeps its `FLASH` entry: the low-level surface reaches raw constants, and nothing reads that group
as an alphabet any more. Accepted: `flash` is now false for `data_coding` 0x11 to 0x13, which no
peer means as immediate display.
- **A report is final unless its `esm_class` or its state says otherwise, and only `ENROUTE` and
`SCHEDULED` say otherwise.** SMPP 3.4 Appendix B lists every other receipt state as final,
`UNKNOWN` and `ACCEPTED` included, so a peer writing `ACCEPTD` for a carrier-accepted step is taken
at its word. Rejected: reading `UNKNOWN` as non-final, which leaves a peer whose receipt body this
library cannot read with no `messageDlr` at all — goal 2 wants that reported as undetermined, not
withheld. Both spellings resolve into `Dlr.intermediate` at the boundary rather than being read a
second time in `DlrMerger`, so the library cannot answer the application one way and conclude the
other. Not every peer marks a transient report 0x20 — an ordinary receipt carrying `stat:ENROUTE`
is common — so the state test is what the marker test cannot replace. `message_state` 0 is 5.0's
`SCHEDULED` and undefined in 3.4; a peer that writes it is read as transient rather than as saying
nothing, maintainer's call, 2026-09-03, since the codec refuses a zero-length integer TLV and so an
absent one cannot land there.
- **A `stat:` an operator spells outside Appendix B is read as the state it names, and the two
researched ones are `FAILED` and CM.com's `DELIVERD`.** Maintainer's call, 2026-09-08, from the
operator-fixture phase: Kaleyra and Route Mobile both document `FAILED` in that field as a terminal
delivery failure, and the research attributes it to Vonage as well; CM.com's own code table prints
`DELIVERD` — eight characters — beside six correct ones. Both were left at `statusMsg: UNKNOWN` —
the same answer a receipt really saying `stat:UNKNOWN` gets, so an application could not tell an
operator's "it failed" from its "I do not know", nor a delivered message from one whose state
could not be read; and `DlrMerger` ranks `UNKNOWN` below `EXPIRED`, reporting a multipart send
carrying a failed segment as expired. They join `receiptStates` alone: `receiptCodes` goes on
writing the seven characters 3.4 defines, so nothing this library sends gains either spelling. Rejected: a `FAILED` member of `MESSAGE_STATE`, which
is 3.4's own numbered table — the code has no number there, so one would have to be invented, and
every consumer's switch would grow a case no `message_state` TLV can carry. Rejected: leaving it
`UNKNOWN` and sending the application to `dlr.receipt.stat` for the state, which reports a terminal
failure as undetermined and leaves the merge ranking it below `EXPIRED`. Rejected: reading the
numeric status tables Syniverse and Route Mobile publish beside it, which are vendor fields of
their own rather than the seven characters `stat:` holds. Accepted: all three of those operators
document `FAILED` and `UNDELIV` as separate codes, and both now resolve to `UNDELIVERABLE` — an
application that must tell them apart reads `dlr.receipt.stat`, which carries what the SMSC wrote.
Accepted: an unmarked `deliver_sm` whose body says one of them now reaches the application as a
report where it used to arrive as an inbound message, which is what every code already in the table
does. What decides a spelling is whether the corpus in `test/operator-receipts.test.ts` can cite the
page it is printed on and no other code could be meant, which is why `DELIVERD` is read and a
spelling nobody publishes is not: a mapping that costs nothing where an operator's own docs merely
contain a typo saves an application everything where they do not.
- **A transient state goes out as an intermediate delivery notification (0x20), every other state as
a delivery receipt (0x04).** Appendix B makes a receipt's `stat` the message's final status, so
0x04 over `ENROUTE` emits the two disagreeing spellings of finality the reading side above has to
reconcile, and goal 3 has our own senders write the marker 3.4 defines. `sendDlr()` takes the list
from `transientStates` in `protocol/dlr.ts`, the same one the reader uses, so the two cannot drift.
Rejected: 0x04 for every state, for the sake of a peer that classifies on the marker — the cost
accepted here is that such a peer stops recognising a transient report as a report at all and hands
its application receipt text as an inbound message, where under 0x04 it would have read the state
from `stat:` and been right. A transient state also carries `err:000`, since a message still on its
way has not failed.
- **A refused PDU is answered from its header, and any 32-bit `sequence_number` is echoed as it
arrived.** Maintainer's call, 2026-09-05 via the interop plan. The header of a framed PDU always
parses, so it carries the answer SMPP 3.4 4.3 asks for, with the status 3.4 names for the part
that would not parse. Rejected: nacking a refused *response*, whose sequence number is one of
ours — the `generic_nack` would land in the peer's own numbering and nack a request of the peer's
we never saw, so a refused response is written back nothing and settles the request it names
instead. An unknown command id with the response bit set takes that branch too: a peer echoing a
sequence number of ours is answering something, and settling it reaches the undetermined outcome
`responseTimeout` would have reached anyway, sooner. Rejected: clamping a sequence number outside 4.7.1's 0x00000001–0x7FFFFFFF into range
before answering, which correlates with nothing at the peer — stacks write the field as a plain
uint32 (ukarim/smscsim signs every unprompted `deliver_sm` with a raw `rand.Int()`), so goal 3
keeps that traffic and `PendingRequests.nextSeqNr()`, the only thing that invents one, is what
holds our own sends inside the spec.
- **The optional parameters run to `command_length` exactly, and the only slack tolerated is one
NULL octet where a peer padded `short_message`.** Maintainer's call, 2026-09-06, from the
Java-client interoperability phase: accepting any parse that merely did not error answered
`ESME_ROK` to a `deliver_sm` whose three trailing octets were never read, dropping the
`receipted_message_id` that makes a receipt a receipt
([interop-tests/findings/05-java-clients.md](../interop-tests/findings/05-java-clients.md)). Goal 2
settles it against goal 3: octets this codec cannot name are a PDU it did not read, so a region
that does not end on `command_length` — the padded read included — is refused with the `tlvs`
reason and `ESME_RINVTLVSTREAM` a truncated TLV value already gets. What the rule costs is paid
once, in `readCstring()`: a trailing C-Octet String a peer left out entirely consumes no octet,
where reporting the terminator it never sent puts every later offset past the declared end and
refuses a bind, and every bodyless response, that used to parse. That composes, so a run of them
at the tail all read empty — `outbind` is the only command with two, and an absent field and an
empty one say the same thing, so goal 2 is not at stake even there. Rejected: keeping the tolerance
for the one to three trailing octets too few to hold a TLV header, which no researched peer sends
and which cannot be told apart from the truncated tail this fixes. Rejected: refusing it as
`body`/`ESME_RINVCMDLEN`, which names the mandatory fields — the part the peer got right.
- **`smsIdFormat` names a notation per place, and normalisation never reaches inside a `<base>-<n>`
id.** An SMSC may answer `submit_sm_resp` in hex and write the receipt's `id:` in decimal, so one
transform over both sides cannot make them equal. `submitResp` covers the `receipted_message_id`
TLV too, which SMPP 3.4 5.3.2.26 defines as the id the `submit_sm_resp` carried: naming one
notation for whichever id a receipt yields would break the peer that sends both. Omitting a place
is what leaving it alone means, so there is no `raw` notation, and a caller-supplied formatter is
refused because it would make the promise that the two ids are comparable unverifiable — `onRequest`
and the PDU on the `dlr` event are the escape hatches. A `<base>-<n>` id parses as no number and so
reaches `expect()` and `collect()` unchanged, which is what keeps `DlrMerger` working; normalising
the base instead would break that pair. The option is on `client()` only, since a `server()` session
writes both ids itself.
- **A concatenated segment is budgeted at 134 octets, which is 153 septets where the SMSC packs them
and 134 octets of anything it does not.** Maintainer's call, 2026-09-09, from the architecture
review of [#95](https://github.com/larvit/larvitsmpp/pull/95): `segmentUnits` handed 153 to
everything but UCS2, so a long `encoding: 'LATIN1'` message went out as segments of 153 octets plus
a 6-octet UDH — 159 on the air where GSM 03.40 carries 140, which no SMSC can deliver. Goal 1 owns
it. There is one budget, 140 less the UDH, and the alphabet decides only what it is counted in, so
Latin-1 and UCS2 both take those 134 octets — 134 characters and 67 — and it is GSM 7-bit's 153
that is the odd number rather than the other way round. `Record<EncodingName, number>` is what makes
a fourth alphabet state its own. Rejected: 134 for GSM 7-bit too, which is the mistake
[GSM 7-bit is sent unpacked](../AGENTS.md#gsm-7-bit-is-sent-unpacked) exists to stop. Accepted: a Latin-1 message past the 140 characters
one SMS holds now costs more segments than it did, and `smsIds` is that much longer.
- **An alphabet the caller named has to carry the message, and a time the format cannot express is
refused, both before a segment goes out.** Maintainer's call, 2026-09-09, from the architecture and
stability reviews of [#96](https://github.com/larvit/larvitsmpp/pull/96): `encoding: 'LATIN1'` on
`あいう` put `42 44 46` — `"BDF"` — on the wire and returned success, `encoding: 'ASCII'`
flattened every character outside 03.38 to a space, and `validityPeriod: new Date('nope')` wrote
`NaNNaNNaNNaNNaNNaNNaN00+` into the PDU. Goal 2 owns all three: bytes that do not say what the
caller asked, reported as sent. `unencodable()` is the single answer to whether an alphabet can
carry a message, as `messageClassOf()` is to whether a `data_coding` carries a class, and it asks
the codec — `decode(encode(c)) === c` per code point — rather than restating the tables beside it,
so the guard cannot drift from what the encoder writes for any one character, and a fourth
alphabet answers by having a codec at all. It is exported for the reason `concatOf()` is: a caller
composing a `submit_sm` through `send()` and `encodeMessage()` would otherwise rewrite the read
this fixed. `match()` cannot be that answer — it doubles as the auto-selection policy `detect()`
reads, where LATIN1 is hardcoded false so nothing picks it, and using it would refuse the 8-bit
binary body Latin-1 is kept for. The guard is
on the named branch alone, so an unspecified send is untouched: every alphabet `detect()` returns
carries every character it was picked for, over the whole code point range. `smppTime.encode()`
returns a `Result`, where the three encoding helpers stayed total: that argument was that
`EncodingName` is a closed set the compiler guards, and `Date | number | string` is not — an
invalid `Date` and `NaN` inhabit it, which hard rule 1 makes a result "wherever the types admit
one", and `decode()` has been fallible for the same reason since it was written. Rejected:
transcoding to UCS2, which overrides the one option the caller wrote in order to override a choice
— the same reason a flash Latin-1 message is refused rather than promoted, and an operator that
accepts only `data_coding` 0x03 would be handed something it never agreed to take. Rejected:
guarding `sendSms()` alone and leaving `smppTime.encode()` writing `NaN`s, which leaves this
library's own published helper composing the garbage the guard exists to stop. Rejected: a
`holds()` member beside `match()` on `Encoding`, a second per-alphabet table to keep in step with
the codec. Rejected: validating the `string` spelling of a time, which is a stamp the caller
formatted for a peer whose format is theirs to name, its width included, where SMPP 3.4 gives the
field 1 or 17 octets. Accepted: a second count past 99d 23:59:59 is refused rather than clamped to
it, a negative one and `Infinity` with it — clamping `86400 * 365` reported success for a year and
put 99 days on the wire, the wrong answer about what happened that the rest of this bullet exists
to remove. The ceiling is this encoder's rather than the format's: 3.4's `YYMMDDhhmmss000R`
carries years and months, which `decode()` reads back, and no fixed number of seconds is either
one, so spelling a second count in days and below is where the guess would go — which is why the
too-long refusal names the `Date` that reaches every instant the absolute form holds, and the
negative one names nothing, there being no period to reach. Rejected: documenting the clamp, which
leaves the caller told a true thing and still sent the wrong period. Accepted: GSM's 0x1B is an
extension prefix rather than a character, so a bare ESC beside one of the ten extension bases is
the one input a per-character reading passes and the encoder then writes as the extended character
— the only composition in any of the three codecs, and not a character a message is written in.
- **A string body is written in the alphabet its own `data_coding` names, and one that alphabet
cannot carry is refused by the codec — `message_payload` on the same terms as `short_message`.**
Maintainer's call, 2026-09-09, from the architecture review of
[#97](https://github.com/larvit/larvitsmpp/pull/97): `objToPdu()` took the codec off the caller's
own `data_coding` and encoded with it whatever the text was, so `data_coding` 3 beside `あいう`
returned `42 44 46` — `"BDF"` — reported as built, while a string `message_payload` was cut to its
low octets whatever `data_coding` said. Goal 2 owns it, as it owns the `sendSms()` guard above.
The line falls at the string: a `Buffer` is octets the caller already chose and goes out as given
under any `data_coding`, which is what keeps goal 8's escape hatch open — the raw UDH, 8-bit binary
and deliberately malformed bodies `interop-tests/` builds are all still buildable — and a string
with no `data_coding` is untouched, detection carrying every character it was picked for. The
guard is `unencodable()` again rather than a second reading, and `unencodableText()` is the
character, its code point and its index said once for both refusals — unexported where
`unencodable()` is published, since wording `{ char, index }` into a sentence rewrites no read a
caller would get wrong, where asking the codec is, and publishing it would freeze this library's
error prose as API for an application whose own refusal should read like itself. Goal 8, from the
architecture review of [#99](https://github.com/larvit/larvitsmpp/pull/99), 2026-09-09. It is
reached through `encodeBody()` in `message.ts`, which is where the `data_coding`-to-text pair already lives:
`encodeBody(text, dataCoding)` is `decodeMessage(buffer, dataCoding)`'s mirror and resolves the
alphabet through the same `encodingByDataCoding()`. `send()` and `sendReturn()` inherit it,
since both build through `buildPdu()`; `sendSms()` does not, and keeps its own guard, because
`splitMessage()` hands the codec a Buffer with nothing left to refuse and the index a segment
could name is not the one in the message. The TLV is encoded rather than merely checked because
`data_coding` names the alphabet of the body wherever it is carried — that is how
`messageOctets()` and `decodeMessage()` read one back, and a `data_sm` has nowhere else to put one
— so refusing what Latin-1 cannot hold while still writing UCS-2 text as Latin-1 octets would
close half of it. `short_message` settles the `data_coding` wherever it carries octets at all, the
order `messageOctets()` reads the two in, so the alphabet a PDU declares is the one its body will
be read under — and a `short_message` on a command whose table declares none is ignored here as
`writeParams()` ignores it, so an empty one, an absent one and one the wire cannot carry are the
same input rather than three. A `data_coding` on a command that declares no such field is honoured
the other way round, since it is `replace_sm`'s only way to name the alphabet its octets are in.
Rejected: refusing a string `message_payload` outright and demanding octets, which contradicts
`short_message` on the same PDU. Rejected: guarding every string-valued field against
`data_coding`, which says nothing about them — a text field on the wire has an alphabet of its
own.
- **A GSM 03.38 message declares `data_coding` 0x00, and an inbound 0x01 is still read as GSM.**
Maintainer's call, 2026-09-09: `dataCodingFor()` and `encodeBody()` both resolved an alphabet
through `consts.ENCODING`, so `encoding: 'ASCII'` went out as 0x01 — SMPP 3.4 5.2.19's *IA5 (CCITT
T.50)/ASCII* — while the codec writes GSM 03.38, where `$` is 0x02 and `@` is 0x00 against IA5's
STX and NUL. Goal 1 owns it, and this library's own reader hid it by resolving both codings to the
same codec. `dataCodingByEncoding` is the single answer to which coding an alphabet is written
under, as `unencodable()` is to whether one can carry a message: the mirror of
`encodingByDataCoding()`, and reached by both the `sendSms()` path and `encodeBody()`'s detected
one rather than each spelling the map again, which is what `sendDlr()` inherits it through. It is
exported for the reason `unencodable()` is — a caller pairing `encodeMessage()`'s octets with a
`data_coding` of its own had only `consts.ENCODING` to reach for, which is the trap. 0x00 is the
*SMSC's* default alphabet rather than 03.38 by name, so it is a convention rather than a guarantee;
it is also what every peer in `interop-tests/` submits under and what LINK Mobility, Route Mobile
and Telesign all publish 03.38 as, where 0x01 names a different alphabet from the one written and
so is wrong whatever the peer makes of it. Reading is untouched, goal 3: those same three map 0x01
to 03.38 too, and Kaleyra and Route Mobile publish that value as known to cause problems, so no
researched peer means IA5 by it. The two tables agree over most of the printable range and part at
0x00-0x09, 0x0B-0x0C, 0x0E-0x1A, 0x1C-0x1F, 0x24, 0x40, 0x5B-0x60 and 0x7B-0x7F — line feed,
carriage return and escape are common to both — which is where a peer that did mean IA5 is
misread. Accepted with it: `consts.ENCODING` loses its `ASCII` alias and keeps `IA5`, the two
names 5.2.19 gives 0x01, because that alias was the only name the two tables shared at different
values and so the only one a reader could carry from the option's vocabulary into SMPP's flat
table; `constsById.ENCODING[0x01]` already read `IA5`, so nothing moves but the forward name.
Rejected: moving `consts.ENCODING.ASCII` to 0x00, which would make that table contradict the
section it exists to spell — the group is SMPP's flat `data_coding` table, not the `encoding`
option's vocabulary, the distinction the `FLASH` removal already drew. Rejected: reading 0x01 as
Latin-1, the closest codec here to IA5, which mojibakes every peer that means GSM for one nothing
researched has found. Accepted: a message already in flight is unmoved — both codings resolve to
the same codec, `messageClassOf()` finds no class in either, and `Reassembler` groups on the
concatenation reference rather than on `data_coding` — so a receipt or a segment that crossed the
change reads exactly as it did.
- **Every text field on the wire is latin1, and what the field cannot carry is refused rather than
truncated.** Maintainer's call, 2026-09-21, the refusals from the security and stability passes on
[#16](https://gitea.larvit.se/larvit/smpp-js/pulls/16). 3.4 calls these fields ASCII, so goal 3
settles the read alone — its generous clause is scoped to reading, and its sender clause is strict.
Goal 1 settles the write, being 3.4 as SMSCs actually run it: an operator routing an alphanumeric
sender through the upper half is traffic to keep, and Node's `ascii` write already put those octets
on the wire, so naming the write latin1 makes the round trip idempotent and no peer sees a change.
Goal 2 settles the two latin1 refusals, each a `size()` that would have agreed with a `write()`
that put something else on the wire: a character past `U+00FF` written as its low octet, and a caller's
own `U+0000`, which a mandatory field's reader takes as the end of the field. Goal 4 settles them
twice over: for one character in every 256 that low octet is `0x00`, and the PDU went out malformed
on the operator's parser. `wantText()` and `wantCstringText()` are the two places that decide the
refusals; every read and write spells `latin1` itself. Rejected: reading latin1 and leaving the write spelled ASCII, which leaves two halves agreeing
only by accident. Rejected: refusing the upper half on send
to stay strict to 3.4's ASCII, which would be a new restriction taking away traffic this library
already sends and operators already accept, on no defect. Rejected: refusing `U+0000` in every
text field, which would buy one spelling by taking a legitimate octet away from the
length-prefixed Octet String, whose length octet is what ends it.
- **A TLV input is keyed by its tag name, or by its decimal id where the table names none, and a
`tagId` beside the key is accepted only where it agrees.** Maintainer's call, 2026-09-27, on the
architecture and product-owner reviews of [#30](https://gitea.larvit.se/larvit/smpp-js/pulls/30). The
key is the one spelling, because a name and a `tagId` that disagreed sent the `tagId`'s tag under
a record keyed as another. A parsed TLV carries its `tagId`, and goal 8's passthrough means a
parsed PDU's `tlvs` relay as they are, so an agreeing copy is read past rather than refused.
Rejected: refusing every `tagId`, which breaks relaying. Rejected: a `tagId` overriding the key,
the 0.5.0 behaviour. Valid while parsed TLVs carry `tagId`.
## The session's life
- **A close arriving after our own `unbind` is a clean unbind, not an error.** Maintainer's call,
2026-08-26: most SMSCs drop the socket instead of answering, so the documented shutdown would
otherwise always report a failure. It does mask a socket that died mid-unbind for an unrelated
reason, which is accepted — the peer sees the same TCP close either way.
- **`close` means the session is over, and a drop the loop will retry is `disconnected`.**
Maintainer's call, 2026-08-31: without the split, an application that opens a replacement client on
`close` ends up holding two binds on one account, which goal 4 forbids.
- **An answer belongs to the link the message arrived on; a receipt does not.** Maintainer's call,
2026-09-01. Rejected: answering on the new link, which succeeds and reports `{}` for a response
that correlates with nothing — goal 2's wrong answer. Accepted: a receipt sent after a refused
response names an id the peer has no record of.
- **`reconnect` takes `{ minDelay, maxDelay }` to retune and `false` to turn off**, so absent means
on and there is one spelling for each. Only `client()` reconnects — a `server()` session is a
connection the peer opened, and nothing at this end can reopen it. The retry timer is `unref()`'d,
so a process with nothing else left to do still exits between attempts.
- **Coming up is not proof a link works, so only one that outlasted `maxDelay` resets the backoff.**
An unreadable stream is found after the bind returns, so resetting on connect gave a link that died
on arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever. A drop
after a healthy link still retries at `minDelay`.
- **`reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
`client()` then resolves only once it is bound.** Maintainer's call, 2026-09-05: an application
started before its SMSC is up otherwise writes that retry itself, around the one this library
already owns. A field on `reconnect` rather than an option of its own, so the combination that
would contradict `false` cannot be written at all — `false` carries no fields — and a top-level
`fromStart` is refused by name rather than ignored. Nothing but the caller's `signal` ends the
wait: a bound of its own would be a second spelling of a deadline the caller already writes with
that signal, and giving up after one is what the default does. A bind the SMSC refuses is
retried like any other failure — rejected: giving up on `ESME_RINVPASWD` and `ESME_RBINDFAIL`,
which would have the initial attempts and a rebind disagree about what a refused bind means, and
gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the
rate goal 4 cares about. The attempts before the first link report nothing, because the session
running one has not reached the application: `disconnected` would have no listener and `close`
would be a lie.
- **`connectTimeout` defaults to 10 s, bounds the whole connect including the TLS handshake, and
`false` is the one way to turn it off.** Maintainer's call, 2026-09-20, serving goal 5: a connect
that never returns is one `reconnect` cannot retry, because the operating system holds the attempt
for around 130 s at Linux's default `tcp_syn_retries` and nothing above it is counting — and no
application chooses that, so it is a default rather than an option that switches on what the caller
obviously wanted. Accepted: the far end sees roughly four times the SYNs against a dead host, one
per ~40 s rather than one per ~160 s, which goal 4 tolerates because the backoff still caps the
rate. `false` spells the operating system's wait, as it does for `reconnect`, and `0` is refused
naming it, so one spelling reaches each result. What expires is reported as the ordinary connect
failure, so the loop retries it like any other, and the message names the peer and whether the TCP
connect or the TLS handshake stalled — different faults, different answers. It settles on
`secureConnect` for a TLS socket, so a peer that accepts and then says nothing is bounded the same
way a black-holed SYN is. Rejected: shipping the option with no default, which left goal 5's "an
option does not switch on the thing the caller obviously wanted" unmet, and would have cost a
second breaking minor plus a reversal of the `0` spelling to correct later. Rejected:
`socket.setTimeout()`, an idle timeout that goes on arming once the link is up. Rejected: bounding
it with `responseTimeout`, which names the wait for an answer on a link that already exists and
would retune both at once. `server()` shares the checker and ignores the option, as it already
ignores `reconnect` — nothing at that end connects out.
- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.**
Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below
16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
link down through `teardown()` and the reconnect loop retries it on a fresh socket with a fresh
framer. Every other codec failure honoured `command_length`, so the stream is still in sync and
the next PDU starts where it says — tearing the link down there cost one peer half its receipts
and its MO to a reconnect loop (`interop-tests/findings/01-smscsim.md`), and left the peer waiting
for answers it was owed. `sessionError` carries every failure of either kind, never coalesced or
suppressed, so a peer that only ever sends garbage is visible in the log rather than silent.
- **A deliberate shutdown drains; an unusable link and an abort do not.** `close()` and `unbind()`
wait on the send window rather than the pending map — the map misses a segment still queued behind
a full window, and finishing a half-sent multipart message is the point. A stream the framer or
the codec cannot read, an aborted `close({ signal })` and a peer's own `unbind` do not drain:
nothing on a dead link can answer, an abort means stop now, and a peer that has declared itself
finished will not answer what it still owes, so draining any of the three would only hold a socket
open for the timeout. `shutdownTimeout` stays a session option rather than a `close()` argument:
`server()` builds sessions on the caller's behalf, so the option is the only composition point.
`SmppServer.close()` reports each session's unfinished drain through `serverError`, because its
own result says nothing but that the listener stopped.
- **`sendSms()` puts every segment of a message on the wire together.** Goal 6: a long message costs
one round trip rather than one per segment. Rejected: sending each segment once the last is
answered, which a receiver waiting for the whole message before answering would deadlock.
- **Every segment of a concatenated message is answered as it arrives, so `sendResp()` on one is the
application's own signal rather than the peer's answer.** Maintainer's call, 2026-09-06, from the
Jasmin interoperability phase: Jasmin dispatches one `submit_sm` per connector at a time and will
not send segment 2 until segment 1 is answered, so holding a group unanswered until it was whole
deadlocked every multi-segment message against a production gateway
([interop-tests/findings/03-jasmin.md](../interop-tests/findings/03-jasmin.md)). Goal 1 has the answer
a real SMSC gives — one `message_id` per `submit_sm`, immediately — so the group's id base is
generated when it opens and each segment is answered `<base>-<n>`, the notation `protocol/message-ids.ts` owns
and `DlrMerger` reads back. The id is therefore fixed by the first segment, which is why an `smsId`
or a refusing `status` passed to `sendResp()` on such a message is an error rather than a silent
no-op. `answeredOnArrival` is on `Sms` because nothing the application can compute says it, and the
discriminant a reader would reach for instead is wrong. A message `sendResp()` still answers itself is
untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for
an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()`
answers every segment it will not carry rather than leaving it unanswered, which is the same stall
in miniature: the field that numbered it where the segment belongs to no group, the retry status
where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
answering every segment but the one that completes the group, which leaves the peer holding some
segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment,
which asks the application to name a message it cannot read yet — what it wants is `sms.smsId`
afterwards. Rejected: an option to keep the old behaviour, a second spelling whose only
distinguishing feature is that it deadlocks. Accepted: a group given up on — expired, evicted, or
dropped with the link — is traffic the peer will not send again, so each one reaches `sessionError`
as well as the log. Rejected there: an exported `MessageLostError` carrying the group, on the
`PduRefusedError` pattern — no `sms` ever fired for that group, so there is nothing in it the
application could act on, and goal 8 does not buy a second exported class to make a count
distinguishable. Accepted: a completing segment whose own answer the socket would not carry still
reaches the application, because the message is whole and correct and the failed answer is on
`sessionError` — a peer that re-sends after the drop is the smaller risk than dropping a message
in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can
never precede the acceptance of the message it reports on.
- **`server()` composes the application's `onRequest` after its own bind handling, and offers it
every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review
of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch
the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up
bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is
what goal 8 means by beating "the application can do this itself". What the library verifies is the
ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind
request on a session already bound, so no bind — a second one on a live session included — and
nothing a peer sends before one can be intercepted however the hook is written. One
`OnRequest` type on both option bags, because a second contract under one name is two spellings of
one goal; widened to accept a plain boolean, as `authenticate` already is, so an observing hook need
not be `async`. Nothing of ours is written for a request whose hook failed, the same on both
surfaces: the library cannot tell one that failed before answering from one that failed after, so
goal 2 reports the outcome as undetermined rather than guessing, and the peer's own
`responseTimeout` is what settles it — the answer `authenticate` failing already takes. A hook that
throws or rejects reaches `sessionError` on the way; one that never settles reaches nothing at all,
and is visible only as the request that was never answered. That takes the keepalive with it, since
a hook broken across the board leaves `enquire_link` unanswered and the peer drops the link — the
back-pressure wanted, because an application that cannot serve a link should not hold one.
`sessionError` rather than `serverError` because the
failure belongs to one session's request, and that channel already carries every failure of one.
The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound
peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`.
Nothing is held for a request the hook answered, so the drain waits on none of it. `OnRequest` stays unexported where
`AuthenticateInput` is exported, because that hook's argument is a shape this library invents and
this one's are two types already published. Rejected: consulting the hook first, which puts
bind and authentication inside the application's reach for nothing. Rejected: a narrower hook
returning a status for the library to write, which makes the answer verifiable but pays a second
contract under a second name for it, and could not express what the session-level hook already
does — answer a bind, a vendor command, a `data_sm` — leaving that error naming something only
half the surface can do. Rejected: falling the request through to the built-in handling on a
failure, which reads as the answer the peer would have had with no hook — true only of a hook
that failed before answering, where one that failed after put a second response on the peer's own
sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the
fall-through could be gated on it, which buys a fail-open path with state and an internal contract
no other collaborator needs.
- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
the duplicate goal 2 forbids, in the direction the window already covers. No completion signal was
added to the `sms` event: `sendResp()` is what an application already calls when it is done with a
message, so it is the one the drain waits for. Counting every inbound request until `sendReturn()`
answered it was rejected: an `onRequest` that deliberately answers nothing would then cost a full
`shutdownTimeout` on every close. The response reaching the wire ends the wait, so a `sendResp()`
the library refused or the socket would not carry leaves `close()` still reporting the message the
peer is owed.
- **The drain's wait on the application ignores `shutdownTimeout: 0`.** Waiting forever is safe for
the peer, whose every request is bounded by `responseTimeout` unless the caller set that to 0 as
well, and unsafe for the application, which nothing bounds — `close()` is what you reach for when
the application is stuck, so it may not block on the application coming unstuck. That half falls
back to `responseTimeout`, the same answer `LinkLife`'s hold already takes — and to that
option's default where it is 0 as well, since neither option is an answer about the application.
- **What the application holds unanswered is capped on constants, and a message past the cap is
refused.** A bound the application cannot raise is the point: an application that answers nothing
would otherwise hold ever more messages, which goal 4 forbids. Reassembly's `maxOctets` is an
option because it bounds what the peer sends; this bounds what the application leaves unanswered.
Maintainer's call, 2026-09-26. Refusing leaves the message with the peer, which will send it again
(goal 2). Rejected: dropping the oldest to make room, which frees nothing while the application
still holds its `Sms`, and stops the drain waiting for a message the peer is owed. Rejected:
pausing the socket, which also stalls every answer and `enquire_link` on the link. Reaching the
bound shows only in the log (goal 8): an event or a public count would be surface for what the
application already knows, since it is the one not answering. A message held past its timeout is
still dropped, so `close()` can report fewer unanswered than there were — accepted, because the
alternative is holding what nothing will answer.
- **A store at its bound answers `ESME_RTHROTTLED` to a submission and `ESME_RX_T_APPN` to a
delivery, a `data_sm` by whichever it stands in for.** Maintainer's call, 2026-09-26, for
reassembly and held messages alike, so "keep it and retry" has one spelling per direction.
`ESME_RTHROTTLED` asks the sender to slow down, which is what the peer outrunning us needs, and
operators send it (Vonage, LINK Mobility, Route Mobile, Jasmin), so clients built against them
meet it (goal 1). Rejected: `ESME_RMSGQFUL`, which names an exhausted queue and no rate.
`ESME_RTHROTTLED` is the SMSC's to send, so an ESME answers with SMPP 3.4's temporary receiver
error, the one an SMSC retries on (goal 3).
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
`onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
merges there loses receipts no peer has a reason to send again. They are cleared where the session
is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the
peer's own counter, so a half-arrived group kept across a drop would take a later message's
segments as readily as the rest of its own, and goal 2 will not hand the application a message
assembled that way. What goes there is traffic already answered, which is why each group reaches
`sessionError` like every other one given up on.
- **The bind state is the session's, `bound()` alone writes it, and it holds through a reconnect's
gap.** Maintainer's call, 2026-09-28. `client()` and `server()` record their bind through
`bound()`, the call a hand-wired session makes, so the state has one writer — goal 8. Clearing the state at `teardown()` was rejected: `bindAllows()` and
`acceptsOptionalParams()` then answer yes to everything while the link is down, so a
receiver-bound client queues a `submit_sm` the peer refuses and a receipt built then carries TLVs a
pre-3.4 peer must not get — goal 4. Valid while the reconnect loop binds again with the same bind
type to the same peer.
- **A message id base is merged at most once.** A receipt carries nothing but `<base>-<n>`, so a
straggler for a message whose group is gone cannot be told from a receipt for a later message the
peer handed the same ids — an SMSC whose id counter restarts with its process is the realistic
case. `DlrMerger` remembers the bases it has finished with, capped and expiring exactly like the
groups, and refuses to open one a second time: the later message gets no `messageDlr`, and an
earlier one whose receipts are still arriving is dropped rather than left to collect the later
one's. Every segment still reaches the application as a `dlr`. `expect()` ignores a lone id, so a
single-part message never claims a base.
- **A send that never reached the socket waits for the next link; one that did is counted, not
resent.** Maintainer's call, 2026-09-01: re-queueing everything unanswered would resend a
`submit_sm` the SMSC accepted and answered into a dead socket, which is delivered and billed twice,
while a request that never left this process can be lost for free. `attempt()` therefore wraps all
three ways a written request can fail in `UnansweredError`; counting only the dropped-link case, as
the first cut did, would have called the commonest one safe to resend. A count rather than a
boolean because `sendSms()` aggregates segments into one `err` slot, and required rather than
optional so every construction site answers. `UnansweredError` stays unexported: `unanswered` is
the one spelling on the public surface. The hold is bounded by `responseTimeout` rather than an
option of its own — that is already the answer to how long one request may wait — and its clock
starts when the send is issued rather than when it first finds the link down, so one budget covers
every hold a single call makes.
- **A send queued for a send-window slot is bounded by the caller's `signal`, and by nothing else.**
Maintainer's call, 2026-09-06, from a review of PR #71: the hold above observes the signal and the
`acquire()` on the next line did not, so a caller that aborted while the window was full waited for
a slot it no longer wanted — at `responseTimeout: 0` for as long as the peer stayed quiet, which is
the deadline the README sends the caller to that signal for. Goal 4 is not re-opened by an
unbounded wait here: the queue is the application's own backlog, unbounded in depth as well as in
time because capping it would refuse a send the application asked for, and nothing in it keeps the
peer waiting — which is what separates it from the inbound stores capped on constants. Rejected:
having `release()` skip a waiter whose signal already fired, which leaves the departed waiter in
the queue where `unfinished()` still counts it and the drain waits on it; the waiter leaves as it
settles instead. Rejected: bounding this wait by `responseTimeout` as the hold is bounded — a full
window is this end's own concurrency draining as the peer answers rather than a link going nowhere,
and that bound would fail a message with more segments than `maxOutstanding` partway through
against a slow peer. The failure is a plain `Error` rather than `UnansweredError`, the same answer
an abort while held for a link already gives. The drain half needs nothing: `close({ signal })` already hands the signal to
`window.idle()`, and `unbind()` taking none is the shape README states.
- **One owner decides whether a link can carry a request, and a bind is what makes it one.**
Maintainer's call, 2026-09-01, extended 2026-09-28; goal 1, since a send on a link not yet bound
comes back `ESME_RINVBNDSTS`. `LinkLife` is told what happened and never reads back into the
session; every other collaborator reads it and keeps no copy. Rejected: gating on the socket being
attached, which admits a send one round trip before the bind is answered, and collaborators that
ask the session, which answered the same question two ways at admit and at release.
`ReconnectLoop.halted` is the one other flag, because `client()` also runs a loop with no session
behind it for `fromStart`; a session's loop is stopped by `Session.stop()` alone.
## Internals and tests
- **Locality work comes before other work until a scoring run reads 7.0.** Maintainer's call,
2026-09-27, when #30 merged under the comprehension floor at 6, 6, 7 and 6; #46, #48 and #49
merged under it on that condition, #49 at 6, 6, 7 and 6 with Locality 5, 5, 6 and 6. Serves goal
8's reshapeable internals, which a reader has to understand before reshaping. Valid until a
scoring run reads 7.0 or above.
- **A listener that rejects is routed by Node's `captureRejections`, not by hand-dispatching.** Both
emitters construct with `captureRejections: true` and implement
`[EventEmitter.captureRejectionSymbol]`, which lands a rejected `async` listener on `sessionError`
or `serverError` beside the synchronous guard in `emit()`. Dispatching `rawListeners()` from
`emit()` instead needs a cast to call them with the event's argument tuple, which hard rule 4
forbids. A rejection reason is `unknown` and `String()` throws on a null-prototype object, so both
handlers normalise through `errorFrom()` rather than inline — a route out of the handler would land
on a bare `process.nextTick` with nothing to catch it.
- **The four-line abort dance is copied across `LinkLife`, `IdleWaiters`, `PendingRequests` and
`SendWindow` rather than extracted.** Architecture review, 2026-09-06: pre-check `aborted`, attach
`{ once: true }`, detach on settle, leave the registry. What differs at each site is the registry
and what settling means — a FIFO handing over a slot, a set released together, a map keyed by
sequence number, a count recomputed at settle — so a shared `Waiters<T>` fits two of the four and
is a shallower module than the copies. Extract it once a fifth appears.
- **`SmppLog` is a five-method contract this library declares, not a dependency.** `debug`, `error`,
`info`, `verbose` and `warn` are what the code actually calls, so an application can satisfy it
with an object literal. `@larvit/log` implements it structurally and stays a devDependency, where
`test/tls.test.ts` passing a real `Log` as the server's logger keeps that compatibility compiled.
- **The TLS tests build their own self-signed certificate in DER** (`test/tls.test.ts`) instead of
adding a devDependency or shelling out to openssl. Maintainer's call, 2026-08-26: the dev image
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI and
fail on every developer machine, and a committed key leaks in a public repository. Valid while the
dev image has no openssl.
- **`src/` is grouped by layer, and imports point down the layers.** Maintainer's call, 2026-09-30,
with the Locality rewrite; the [map](../AGENTS.md#architecture) names the order and places each
root file in a layer. Serves goal 8: internals are reshapeable only once a reader can find them,
and every comprehension panel navigated by the map. Rejected: `src/` flat until a module has to
move for another reason. Valid while the map is what readers navigate by.
- **`test/` stays flat, and a file there is named for the question it answers rather than for the
module it covers.** Architecture review, 2026-09-08, at 18 test files: what keeps that count honest
is the naming rule rather than a tree — `operator-receipts.test.ts` holds a corpus defined by where
it came from, cutting across four modules, where filing it by module would enter each new operator
twice. A split also has to be made twice, since `test` and `test:compiled` each carry a path of
their own. The four files that are not tests are the exception the rule needs stated:
`dummy-smsc.ts`, `raw-pdus.ts`, `reference-smpp.d.ts` and `teardown.ts` answer no question and are
named for what they hold.
- **CI tests on Linux only; `src/` keeps off what is known to break on macOS or Windows.** Maintainer's
call, 2026-09-14. Nothing verifies either platform, so the code avoids what is known to differ there:
shelling out, a path joined by hand, a signal Windows does not deliver, a Unix socket or a file mode.
That binds what `dist/` runs; the container tooling, `interop-tests/` and the `package.json` scripts
run on Linux by goal 10. Rejected: macOS and Windows runners, on GitHub's mirror or as Gitea
host-mode runners on a Windows VM and a Mac.
- **GitHub mirrors Gitea without pruning, and a ref deleted on Gitea is deleted on GitHub by a run of
its own.** Maintainer's call, 2026-09-14; valid while nothing deploys from GitHub.
`.gitea/workflows/mirror.yaml` never prunes, and `mirror-delete.yaml` runs once per deleted ref. A
delete run that fails or outlives Gitea's queue timeout, or a push run that cloned before the
delete, leaves the ref on GitHub until the delete run is re-run. Accepted: a stale ref there is
harmless, and refs only GitHub has must survive.
-55
View File
@@ -1,55 +0,0 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist/', 'dist-test/'] },
eslint.configs.recommended,
tseslint.configs.strictTypeChecked,
tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'@typescript-eslint/no-floating-promises': ['error', {
allowForKnownSafeCalls: [
{ from: 'package', name: ['describe', 'it', 'test'], package: 'node:test' },
],
}],
'@typescript-eslint/no-non-null-assertion': 'error',
'no-console': 'error',
},
},
{
files: ['src/**/*.ts'],
rules: {
complexity: ['error', 10],
'max-lines': ['error', { max: 350, skipBlankLines: true, skipComments: true }],
'max-lines-per-function': ['error', { max: 40, skipBlankLines: true, skipComments: true }],
'max-params': ['error', 5],
},
},
{
// The spec tables are data: their length tracks the specification, not any complexity.
files: ['src/codec/{commands,constants,encodings,errors,tlvs,types}.ts'],
rules: { 'max-lines': 'off' },
},
{
// ESLint counts every ?. and ?? in dlrFromPdu as a branch; the 19 is 26 lines of flat field resolution.
files: ['src/protocol/dlr.ts'],
rules: { complexity: ['error', 19] },
},
{
// ESC (0x1B) is the GSM 03.38 escape character, so it belongs in these patterns.
files: ['src/codec/encodings.ts'],
rules: { 'no-control-regex': 'off' },
},
{
files: ['eslint.config.js'],
extends: [tseslint.configs.disableTypeChecked],
},
);
+6
View File
@@ -0,0 +1,6 @@
'use strict';
exports.server = require(__dirname + '/lib/server');
exports.client = require(__dirname + '/lib/client');
exports.utils = require(__dirname + '/lib/utils');
exports.defs = require(__dirname + '/lib/defs');
-101
View File
@@ -1,101 +0,0 @@
# interop-tests
How the experiments in [README.md](README.md) are run and recorded. Read README.md first, and the
root [AGENTS.md](../AGENTS.md) for the conventions all code here follows.
## Layout
| | |
| --- | --- |
| `compose.<peer>.yaml` | Overlay on the root `compose.yaml`: the peer's services, a `capture` sidecar in the peer's network namespace, and `node` given `depends_on` the peer |
| `<peer>.test.ts` | `node:test` file driving our library against the peer, in the style of `test/`. Reads `PEER_HOST`/`PEER_PORT` from env, defaulting to the compose service name and 2775 |
| `peers/<peer>/` | Dockerfile and config for a peer without a published image, or one that needs config files |
| `captures/` | pcapng and tshark JSON from a run. Gitignored |
| `findings/<NN>-<peer>.md` | What a phase found. Committed after every phase |
| `run.py` | Brings a peer up, runs its tests, stops the capture, tears down, analyses the capture |
## Running
```bash
./interop-tests/run.py <peer> # one peer, all its tests
./interop-tests/run.py <peer> --keep # leave the peer up for a manual look
```
`run.py` is the one spelling; it wraps
```bash
docker compose -f compose.yaml -f interop-tests/compose.<peer>.yaml run --rm --use-aliases node node --test interop-tests/<peer>.test.ts
docker compose -f compose.yaml -f interop-tests/compose.<peer>.yaml stop capture
docker compose -f compose.yaml -f interop-tests/compose.<peer>.yaml down -v
```
and then decodes `captures/<peer>.pcapng` with tshark (`-d tcp.port==<port>,smpp -Y smpp -T json`),
printing the command histogram and the counts of `_ws.malformed` and error-severity `_ws.expert`.
Both counts must be zero for a phase to pass, and an empty capture, or one carrying no bind and its
response, fails too.
A peer whose scenarios send malformed PDUs on purpose — jsmpp does, to prove they are refused — has
no way to say so, so its run exits non-zero every time and its findings file carries the count that
is expected. Give the runner an expected count per peer, and a deviation from it becomes the signal
that a bare threshold cannot be: today a third malformed frame appearing beside jsmpp's two
deliberate ones looks exactly like the two.
## Rules for an experiment
1. Peers run in Docker with full patch-version pins. Nothing is installed on the host. Node runs
only through the `node` service. Every peer service caps its logs (`logging: json-file`,
`max-size`/`max-file`) and healthchecks something the peer does not log a stack trace for —
SMPPSim once filled the whole host disk in twenty minutes from a TCP-probe healthcheck.
2. `src/` and `test/` are read-only during an experiment. A defect is recorded with a reproducer,
never fixed here — a fix is a separate change with a regression test in `test/`.
3. No git command that changes state: no add, commit, push, checkout, stash, reset. The
orchestrator commits after each phase.
4. A peer that will not come up is time-boxed: after about an hour of trying, record `blocked` with
everything tried, and stop.
5. `down -v` at the end of every run. Locally built images are kept, and their tag goes in the
findings.
`run.py` runs in the foreground with a long timeout; an agent that backgrounds it is never woken
when it ends.
6. Scratch files live outside the repo, in the directory the orchestrator names.
7. A finding says what happened, what the spec or the peer's docs say, and how to reproduce it.
Wording is neutral: a mismatch is a mismatch until a reader decides whose it is.
## Findings file
```markdown
# <NN> <peer>
Date, images and tags, the commit of this repo, host Docker version.
## Setup
Commands that worked, and what did not, so the next run starts where this one ended.
## Scenarios
| Id (from README.md) | Result (pass / fail / blocked / not run) | Evidence (test name, log line, tshark frame) |
## Defects in @larvit/smpp
One subsection each: what happened, what the spec or the peer's docs say, reproducer (PDU hex or
test), severity.
## Peer quirks
Behaviour of the peer worth knowing that is not our defect.
## Open questions
```
Then add the file to README.md's findings table.
## Fixing what a phase found
Every defect a phase records is fixed before the next phase runs — a fix can change behaviour in
ways the next experiment must see. One fix per defect class, as its own change:
1. A worktree on a branch off `origin/main` (never `origin/v0.4.0`, the 0.4.0 code), named
for the defect.
2. Regression tests in `test/` first, naming the behaviour with the reproducer from the findings;
then the implementation; then the decision record in `docs/decisions.md` where the fix settles
a question of the wire or the session's life.
3. `/larv-review` on the branch, with the pull request based on `main`. When it marks the PR
ready, fast-forward it.
4. Back in the experiments worktree: fast-forward `main`, rerun the experiment that found the
defect, delete the workaround its test carried, and note the fix in the findings file.
-104
View File
@@ -1,104 +0,0 @@
# interop-tests
Ten real SMPP implementations, run against this library in both directions, with every session
decoded independently by tshark so no result rests on our own view of the wire. It exists because
the unit suite and this library's own dummy peers agree with themselves; these peers do not.
It ran between 2026-09-05 and 2026-09-08 and found twelve defects, all fixed. This file replaces
`PLAN.md`, which the findings below still cite by name.
[AGENTS.md](AGENTS.md) is how a run works: the layout, `run.py`, what the capture must show, and
the rules an experiment follows.
## What it found
| Peer | Findings |
| --- | --- |
| ukarim/smscsim | [01-smscsim.md](findings/01-smscsim.md) |
| SMPPSim | [02-smppsim.md](findings/02-smppsim.md) |
| Jasmin | [03-jasmin.md](findings/03-jasmin.md) |
| Kannel | [04-kannel.md](findings/04-kannel.md) |
| jsmpp, Cloudhopper | [05-java-clients.md](findings/05-java-clients.md) |
| python-smpplib, php-smpp | [06-python-php.md](findings/06-python-php.md) |
| smppload, smpp-dumb-client | [07-load.md](findings/07-load.md) |
| Operator documentation, as fixtures | [09-operator-fixtures.md](findings/09-operator-fixtures.md) |
Each records what the peer does on the wire, every defect with a reproducer, and the peer's own
quirks — several findings are the peer's bug, not ours, and say so.
## Peers
Our client binds to these:
| Peer | What it is for | Run |
| --- | --- | --- |
| **Jasmin 0.11.0** | A production gateway with an independent codec, SAR segmentation, UUID ids, and a DLR pipeline that can use `data_sm` | `jookies/jasmin:0.11.0` + `redis:8.8.2-alpine` + `rabbitmq:3.13.7-management-alpine`, bootstrapped over `jcli` by `peers/jasmin/bootstrap.py` |
| **SMPPSim 2.6.11** | The richest fault injection available: per-state receipt percentages, delayed and intermediate receipts, queue-full, loopback, SMSC-initiated `outbind`, receipts with or without TLVs | Built from `kwahome/smpp-sim-docker`; nine `.props` variants under `peers/smppsim/` |
| **ukarim/smscsim 0.2.0** | Zero setup and MO injection from a web page — the smoke test that proves the harness | `ukarim/smscsim:0.2.0`. No PDU validation, so it proves nothing about strictness |
These bind to our server:
| Peer | What it is for | Run |
| --- | --- | --- |
| **Kannel 1.4.5** | The most deployed real ESME there is; parses our receipts with the parser most operators' customers run, and declares 3.4 or 3.3 on demand | `debian:bookworm-20260824-slim` + the distribution package; four `.conf` variants under `peers/kannel/` |
| **jsmpp** | Strict and low-level: the driver builds UDH, `sar_*` and `message_payload` bytes by hand, and rejects an answer it dislikes | Maven build at a pinned commit, `peers/jsmpp/` |
| **Cloudhopper** | The one peer with real windowing knobs, plus a TLS client | Maven build at a pinned commit, `peers/cloudhopper/`. Its 2015-era TLS client cannot do 1.3, so that scenario caps the server at 1.2 |
| **python-smpplib 2.2.4** | An independent GSM 03.38 table to cross-check ours character by character | `python:3.12.14-slim-bookworm`, `peers/python/` |
| **php-smpp** | Three long-message spellings from one client, and separate transmitter and receiver binds | `php:8.4.25-cli` at a pinned commit, `peers/php/`. Its socket guard uses a check PHP 8 broke, so the image patches it |
| **smpp-dumb-client** | A genuinely enforced bounded window, which is what tests backpressure rather than raw rate | Go build at a pinned commit, `peers/dumbclient/` |
| **smppload** | Intended for throughput; its own `bind_transceiver` is two octets shorter than it declares, so it never binds. Kept as a live reproducer that our server refuses the stream rather than hanging | Erlang build, `peers/smppload/` |
## Scenario ids
The findings cite these. `fixture` means a raw-socket peer in our own suite, because no open
implementation emits that shape on demand.
| # | Scenario | Peers |
| --- | --- | --- |
| C1 | Bind each type, `enquire_link` both ways, `unbind` | all SMSC peers |
| C2 | Text-only receipts, no TLVs | SMPPSim |
| C3 | Receipts with `receipted_message_id` and `message_state` | Jasmin, SMPPSim |
| C4 | Intermediate then final receipt | SMPPSim |
| C5 | Failure states, and the worst segment winning a merge | SMPPSim |
| C6 | A receipt delayed past a link drop | SMPPSim |
| C7 | Long MT in GSM and UCS-2, 2, 3 and 10 segments | SMPPSim, Jasmin |
| C8 | Long MO as UDH 8-bit, UDH 16-bit, `sar_*` and `message_payload` | Jasmin, jsmpp, SMPPSim |
| C9 | MO or receipt on `data_sm` | Jasmin |
| C10 | Unknown command id, malformed and vendor TLVs | fixture, jsmpp |
| C11 | Bind refused, and the backoff that must not flood | Jasmin, SMPPSim, a closed port |
| C12 | Throttling and queue-full on submit | Jasmin, SMPPSim, smscsim |
| C13 | A slow SMSC and a full window | Jasmin, SMPPSim |
| C14 | TLS against a public certificate authority | not run — see [Untested](#untested) |
| C15 | `interfaceVersion` 0x50, and a peer answering 3.3 or nothing | SMPPSim, fixture |
| C16 | Receipt text variants from operator documentation | fixture |
| C17 | Encodings round trip | SMPPSim |
| C18 | `outbind` from the SMSC | SMPPSim |
| S1 | Kannel at "34" and at "33", submitting, receipts, MO | Kannel |
| S2 | Long messages in every spelling to our server | jsmpp, php-smpp, python-smpplib |
| S3 | Unhandled and unknown commands, and whether a strict client accepts our answers | jsmpp |
| S4 | Separate transmitter and receiver binds | php-smpp |
| S5 | Window pressure against a slow listener | Cloudhopper |
| S6 | A peer that never sends a keepalive | smpp-dumb-client |
| S7 | A production gateway as the ESME, parsing our receipts | Jasmin |
| S8 | Throughput, with receipts and long messages | smppload — blocked, see above |
| S9 | A bounded window under load | smpp-dumb-client |
| S10 | TLS from a Java client | Cloudhopper |
| S11 | Encodings from another implementation's encoder | python-smpplib |
## Untested
Three things this suite never exercised. None is a known defect; each is a claim resting on the
specification and on Node rather than on a peer having agreed.
- **A TLS handshake against a certificate a public authority signed.** Every TLS test here uses a
certificate generated for the test, so what is proven is that the handshake works and that a bad
certificate is refused. Verifying a real chain is Node's job and we pass `tls.ConnectionOptions`
through untouched, which is why this is a thin risk rather than none.
- **A peer that genuinely speaks SMPP 5.0.** `interfaceVersion: 0x50` is tested against peers that
answer 3.4 or answer nothing, so what 5.0 declares back is unobserved.
- **An SMSC written by someone who never sees this code.** Every peer here is open source and
configured by us. A closed commercial SMSC is the one thing a free suite cannot buy, and the first
operator integration is where that gets answered.
The research behind the peer choices and the operator quirks, one source URL per claim, is in
`research/`. Ask before trusting a claim there that a peer's own docs would settle.
-214
View File
@@ -1,214 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test, { after, describe } from 'node:test';
import type { Session } from '../src/session/session.ts';
import type { Sms } from '../src/session/sms.ts';
import type { SmppServer } from '../src/server/server.ts';
import { server } from '../src/server/server.ts';
const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080';
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
const TLS_PORT = Number(process.env.TLS_PORT ?? '2776');
/** How long the "slow" server holds a submit_sm before answering it - long enough that a burst of
* concurrent submits genuinely queues behind a small window instead of finishing before it matters. */
const SLOW_DELAY_MS = 300;
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;
}
type DriverResult = Record<string, unknown>;
async function driver(path: string, params: Record<string, string> = {}): Promise<DriverResult> {
const url = `http://${CLOUDHOPPER_HOST}${path}?${new URLSearchParams(params).toString()}`;
const response = await fetch(url);
return response.json() as Promise<DriverResult>;
}
const manualTexts = new Set<string>();
const allSms: { session: Session; sms: Sms }[] = [];
function attach(session: Session): void {
session.on('sms', sms => {
allSms.push({ session, sms });
if (manualTexts.has(sms.message)) return;
// The slow server this phase's window scenarios need: every ordinary submit is held for
// SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window.
void delay(SLOW_DELAY_MS).then(() => sms.sendResp());
});
}
const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT });
assert.equal(serverErr, undefined);
assert.ok(smpp);
smpp.on('session', attach);
const key = readFileSync('/shared-certs/server.key');
const cert = readFileSync('/shared-certs/server.crt');
// Cloudhopper's SSL client (Netty 3.9.6.Final, from 2015) cannot complete a TLS 1.3 handshake - see
// findings/05-java-clients.md, Peer quirks. Capped here for S10 only; the plain listener above is
// unrestricted.
const { err: tlsServerErr, server: tlsSmpp } = await server({
authenticate: () => true,
idleTimeout: 40_000,
port: TLS_PORT,
tls: { cert, key, maxVersion: 'TLSv1.2' },
});
assert.equal(tlsServerErr, undefined);
assert.ok(tlsSmpp);
tlsSmpp.on('session', attach);
after(async () => {
await smpp.close();
await tlsSmpp.close();
});
async function waitForSessionCount(server_: SmppServer, count: number, budget = 15_000): Promise<Session[]> {
const found = await waitFor(() => ([...server_.sessions].length >= count ? [...server_.sessions] : undefined), budget);
assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`);
return found;
}
describe('S5 - window pressure against a slow sms handler (target 11)', () => {
for (const windowSize of [1, 10, 50]) {
test(`window ${String(windowSize)}: every request answered, none twice, order preserved`, async () => {
const session = `w${String(windowSize)}`;
const bind = await driver('/bind', { password: 'chpw', session, systemId: `ch-${session}`, windowSize: String(windowSize) });
assert.equal(bind.ok, true);
await waitForSessionCount(smpp, 1);
const count = windowSize === 50 ? 60 : windowSize * 3;
const burst = await driver('/windowBurst', {
count: String(count), prefix: session, session, timeoutMs: '30000',
});
assert.equal(burst.ok, true);
const results = burst.results as { index: number; messageId?: string; ok: boolean }[];
assert.equal(results.length, count);
assert.ok(results.every(r => r.ok), `every submit answered: ${JSON.stringify(results.filter(r => !r.ok))}`);
const ids = results.map(r => r.messageId);
assert.equal(new Set(ids).size, ids.length, 'no message id answered twice');
assert.ok((burst.peakWindowSize as number) <= windowSize, `peak window ${String(burst.peakWindowSize)} stayed within ${String(windowSize)}`);
// "Order preserved" here means each response correlates to its own request rather than a
// different one - guaranteed by Cloudhopper's own sequence-number-keyed window, which is
// exactly why the per-index messageId uniqueness above is the meaningful assertion: each
// of the `count` concurrent callers blocks on its own submit() and gets its own answer,
// racing only on which of them the OS schedules onto the window's free slot(s) first.
const arrived = allSms.filter(entry => entry.sms.message.startsWith(`${session}-`)).length;
assert.equal(arrived, count);
await driver('/unbind', { session });
});
}
});
describe('S5 - request expiry shorter than the handler delay (target 11)', () => {
test('the peer reports the expiry itself; our side is not left in a bad state', async () => {
const bind = await driver('/bind', {
password: 'chpw', requestExpiryTimeout: '100', session: 'expiry', systemId: 'ch-expiry',
windowMonitorInterval: '50', windowSize: '1',
});
assert.equal(bind.ok, true);
await waitForSessionCount(smpp, 1);
const text = 'expiry-probe';
manualTexts.add(text);
const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' });
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
assert.ok(sms);
// Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window
// monitor gives up on it first - recorded, not asserted against, since that is the peer's call.
await delay(1000);
await sms.sendResp();
const result = await submitted;
assert.equal(result.ok, false);
// Cloudhopper's window monitor gives up on the expired slot with a RecoverablePduException,
// not the SmppTimeoutException its own per-call timeoutMs would throw - the two expiries are
// distinct mechanisms and this is the window monitor's own name for it.
assert.match(String(result.errorClass), /RecoverablePduException/);
const health = await driver('/health');
assert.equal(health.ok, true);
await driver('/unbind', { session: 'expiry' });
});
});
describe('S10 - Cloudhopper SSL client against our server({ tls })', () => {
test('handshake, bind, submit over TLS', async () => {
const bind = await driver('/bind', {
password: 'chsslpw', port: String(TLS_PORT), session: 'tls', systemId: 'ch-tls', useSsl: 'true',
});
assert.equal(bind.ok, true);
await waitForSessionCount(tlsSmpp, 1);
const text = 'over-tls-phase5';
const result = await driver('/submit', { session: 'tls', text });
assert.equal(result.ok, true);
assert.equal(result.commandStatus, 0);
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
assert.ok(sms);
await driver('/unbind', { session: 'tls' });
});
});
describe('a refusing status is surfaced back to Cloudhopper', () => {
test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches Cloudhopper in the response', async () => {
const bind = await driver('/bind', { password: 'chpw', session: 'refuse', systemId: 'ch-refuse' });
assert.equal(bind.ok, true);
await waitForSessionCount(smpp, 1);
const text = 'ch-refuse-me';
manualTexts.add(text);
const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' });
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
assert.ok(sms);
await sms.sendResp({ status: 'ESME_RMSGQFUL' });
const result = await submitted;
assert.equal(result.ok, true);
assert.equal(result.commandStatus, 0x00000014);
await driver('/unbind', { session: 'refuse' });
});
});
-50
View File
@@ -1,50 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
volumes:
# The build-time self-signed cert cloudhopper's image trusts (S10) - copied out at container
# start so node's test file can load the same key/cert into server({ tls }). Never committed.
cloudhopper-tls:
services:
cloudhopper:
build: ./interop-tests/peers/cloudhopper
image: interop-cloudhopper:5.0.10-ae6485a
command: ["node", "2775"]
<<: *log-limits
volumes:
- cloudhopper-tls:/shared-certs
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
# cloudhopper only ever dials node on this container's own network namespace, so this sees exactly
# its side of every scenario below (same pattern as compose.kannel.yaml). Both the plain (2775) and
# TLS (2776) listeners node opens are on this one veth.
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:cloudhopper"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
cloudhopper:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775 or tcp port 2776", "-w", "/captures/cloudhopper.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
volumes:
- cloudhopper-tls:/shared-certs
depends_on:
capture:
condition: service_started
cloudhopper:
condition: service_healthy
-103
View File
@@ -1,103 +0,0 @@
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: all four are pure
# outbound TCP clients, so sharing one netns is only a source-IP detail. It never exits, because a
# client that finishes early would take the namespace, and every other conversation, with it.
dumbclient-netns:
image: nicolaka/netshoot:v0.16
<<: *log-limits
command: ["sleep", "infinity"]
init: true
dumbclient-w2000:
build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b
<<: *log-limits
network_mode: "service:dumbclient-netns"
command: ["conf/window2000.yml"]
healthcheck: *dumbclient-healthcheck
depends_on:
dumbclient-netns:
condition: service_started
# S9's comparison run: window below maxHeldMessages (1000, options.ts), where nothing
# should ever be throttled - see findings/07-load.md.
dumbclient-w500:
build: ./interop-tests/peers/dumbclient
image: interop-dumbclient:de0334b
<<: *log-limits
network_mode: "service:dumbclient-netns"
command: ["conf/window500.yml"]
healthcheck: *dumbclient-healthcheck
depends_on:
dumbclient-netns:
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-netns"
environment:
DUMBCLIENT_BIN: /app/smpp-dumb-client-noping
command: ["conf/idle.yml"]
healthcheck: *dumbclient-healthcheck
depends_on:
dumbclient-netns:
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-netns"
command: ["conf/soak.yml"]
healthcheck: *dumbclient-healthcheck
depends_on:
dumbclient-netns:
condition: service_started
# Every dumbclient-* service shares dumbclient-netns's namespace (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-netns"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
dumbclient-netns:
condition: service_started
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
-139
View File
@@ -1,139 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
# Bare TCP connect, no bytes written - Kannel's own healthcheck pattern (interop-tests/compose.kannel.yaml).
# Jasmin's SMPP codec, like SMPPSim's, stack-traces on a probe that writes anything to 2775, so this
# probes the HTTP API port instead, which answers (or ignores) a bare connect harmlessly.
x-jasmin-healthcheck: &jasmin-healthcheck
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/1401'"]
interval: 1s
retries: 60
timeout: 2s
x-rabbit-healthcheck: &rabbit-healthcheck
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 2s
retries: 60
timeout: 5s
x-jasmin-bootstrap-image: &jasmin-bootstrap-image
image: python:3.13.7-slim-bookworm
<<: *log-limits
volumes:
- ./interop-tests/peers/jasmin:/bootstrap:ro
command: ["python3", "-u", "/bootstrap/bootstrap.py"]
services:
jasmin-redis:
image: redis:8.8.2-alpine
<<: *log-limits
jasmin-rabbit:
# 0.11.0's txamqp client declares transient/non-exclusive queues, a feature RabbitMQ 4.x
# refuses by default ("transient_nonexcl_queues... not permitted anymore"), so jasmind's
# RouterPB/DLRThrower services fail to start against rabbitmq:4.3.5. Falling back to the 3.13
# series (confirmed against 3.13.7) starts clean - see findings/03-jasmin.md.
image: rabbitmq:3.13.7-management-alpine
<<: *log-limits
environment:
RABBITMQ_DEFAULT_PASS: guest
RABBITMQ_DEFAULT_USER: guest
healthcheck: *rabbit-healthcheck
jasmin:
image: jookies/jasmin:0.11.0
<<: *log-limits
environment:
AMQP_BROKER_HOST: jasmin-rabbit
REDIS_CLIENT_HOST: jasmin-redis
depends_on:
jasmin-redis:
condition: service_started
jasmin-rabbit:
condition: service_healthy
healthcheck: *jasmin-healthcheck
# group, users, the smppc connector to our own server() and the MT/MO routes - see
# interop-tests/peers/jasmin/bootstrap.py. Runs once and exits; node waits for it.
jasmin-bootstrap:
<<: *jasmin-bootstrap-image
environment:
JCLI_HOST: jasmin
depends_on:
jasmin:
condition: service_healthy
# dlr-thrower's dlr_pdu is a [dlr-thrower] config-file setting, not a jcli/connector key (target
# 4 / C9) - a second instance with its own config is the only way to flip it without touching the
# main instance's receipts. Its own redis/rabbit rather than sharing the main pair: two unrelated
# Jasmin instances sharing a broker is exactly the entanglement CLAUDE.md's compose rule bans.
jasmin-datasm-redis:
image: redis:8.8.2-alpine
<<: *log-limits
jasmin-datasm-rabbit:
image: rabbitmq:3.13.7-management-alpine
<<: *log-limits
environment:
RABBITMQ_DEFAULT_PASS: guest
RABBITMQ_DEFAULT_USER: guest
healthcheck: *rabbit-healthcheck
jasmin-datasm:
image: jookies/jasmin:0.11.0
<<: *log-limits
environment:
AMQP_BROKER_HOST: jasmin-datasm-rabbit
REDIS_CLIENT_HOST: jasmin-datasm-redis
volumes:
# Not bind-mounted straight over /etc/jasmin/jasmin.cfg: the entrypoint's `sed -i` renames a
# temp file over its target, which the kernel refuses for a bind-mounted path ("Device or
# resource busy"). Copying it into place first, over a normal writable file, sidesteps that.
- ./interop-tests/peers/jasmin/jasmin-datasm.cfg:/custom-cfg/jasmin.cfg:ro
entrypoint: ["bash", "-c"]
command:
- >-
cp /custom-cfg/jasmin.cfg /etc/jasmin/jasmin.cfg &&
exec /docker-entrypoint.sh jasmind.py --enable-interceptor-client --enable-dlr-thrower --enable-dlr-lookup -u jcliadmin -p jclipwd
depends_on:
jasmin-datasm-redis:
condition: service_started
jasmin-datasm-rabbit:
condition: service_healthy
healthcheck: *jasmin-healthcheck
jasmin-datasm-bootstrap:
<<: *jasmin-bootstrap-image
environment:
CONNECTOR_CID: upstreamds
CONNECTOR_USERNAME: upstreamdsesme
JCLI_HOST: jasmin-datasm
depends_on:
jasmin-datasm:
condition: service_healthy
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:jasmin"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
jasmin:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/jasmin.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
jasmin-bootstrap:
condition: service_completed_successfully
jasmin-datasm-bootstrap:
condition: service_completed_successfully
-40
View File
@@ -1,40 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
services:
jsmpp:
build: ./interop-tests/peers/jsmpp
image: interop-jsmpp:3.0.3-a24db96
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
# jsmpp only ever dials node:2775 on this container's own network namespace, so this sees exactly
# its side of every scenario below (same pattern as compose.kannel.yaml).
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:jsmpp"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
jsmpp:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/jsmpp.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
jsmpp:
condition: service_healthy
-102
View File
@@ -1,102 +0,0 @@
x-kannel-image: &kannel-image
build: ./interop-tests/peers/kannel
image: interop-kannel:1.4.5-12
x-bearerbox-healthcheck: &bearerbox-healthcheck
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/13000'"]
interval: 1s
retries: 30
timeout: 2s
x-smsbox-healthcheck: &smsbox-healthcheck
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/13013'"]
interval: 1s
retries: 30
timeout: 2s
services:
# Main variant: interface-version 34, transceiver, max-pending-submits 10, wait-ack 5 - the S1/S6/S11
# scenarios and the only variant the capture sidecar watches.
kannel-bearerbox:
<<: *kannel-image
command: ["bearerbox", "/etc/kannel/main.conf"]
healthcheck: *bearerbox-healthcheck
kannel-smsbox:
<<: *kannel-image
command: ["smsbox", "/etc/kannel/main.conf"]
depends_on:
kannel-bearerbox:
condition: service_healthy
healthcheck: *smsbox-healthcheck
# interface-version "33": receipts to this bind must carry no TLVs and still correlate (target 8).
kannel-iv33-bearerbox:
<<: *kannel-image
command: ["bearerbox", "/etc/kannel/iv33.conf"]
healthcheck: *bearerbox-healthcheck
kannel-iv33-smsbox:
<<: *kannel-image
command: ["smsbox", "/etc/kannel/iv33.conf"]
depends_on:
kannel-iv33-bearerbox:
condition: service_healthy
healthcheck: *smsbox-healthcheck
# max-pending-submits 1: a burst of sendsms calls must still all arrive, in order, all answered.
kannel-maxp1-bearerbox:
<<: *kannel-image
command: ["bearerbox", "/etc/kannel/maxpending1.conf"]
healthcheck: *bearerbox-healthcheck
kannel-maxp1-smsbox:
<<: *kannel-image
command: ["smsbox", "/etc/kannel/maxpending1.conf"]
depends_on:
kannel-maxp1-bearerbox:
condition: service_healthy
healthcheck: *smsbox-healthcheck
# transceiver-mode false: separate TX and RX binds; receipts/MO must go out the RX bind only.
kannel-notrx-bearerbox:
<<: *kannel-image
command: ["bearerbox", "/etc/kannel/notransceiver.conf"]
healthcheck: *bearerbox-healthcheck
kannel-notrx-smsbox:
<<: *kannel-image
command: ["smsbox", "/etc/kannel/notransceiver.conf"]
depends_on:
kannel-notrx-bearerbox:
condition: service_healthy
healthcheck: *smsbox-healthcheck
# Only the main variant's bearerbox is captured: every Kannel variant dials out to the same
# node:2775, but a container's own network namespace only sees the traffic that crosses its own
# veth, so this sees exactly the main variant's PDUs.
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:kannel-bearerbox"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
kannel-bearerbox:
condition: service_healthy
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/kannel.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
kannel-smsbox:
condition: service_healthy
kannel-iv33-smsbox:
condition: service_healthy
kannel-maxp1-smsbox:
condition: service_healthy
kannel-notrx-smsbox:
condition: service_healthy
-40
View File
@@ -1,40 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
services:
php:
build: ./interop-tests/peers/php
image: interop-php:8.4.25-1d3b53c
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
# php only ever dials node:2775 on this container's own network namespace, so this sees exactly
# its side of every scenario below (same pattern as compose.jsmpp.yaml).
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:php"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
php:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/php.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
php:
condition: service_healthy
-40
View File
@@ -1,40 +0,0 @@
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
services:
python:
build: ./interop-tests/peers/python
image: interop-python:2.2.4-3.12.14
command: ["node", "2775"]
<<: *log-limits
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"]
interval: 1s
retries: 30
timeout: 2s
# python only ever dials node:2775 on this container's own network namespace, so this sees exactly
# its side of every scenario below (same pattern as compose.jsmpp.yaml).
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:python"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
python:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/python.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
python:
condition: service_healthy
-46
View File
@@ -1,46 +0,0 @@
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
-142
View File
@@ -1,142 +0,0 @@
x-smppsim-healthcheck: &smppsim-healthcheck
# Not a raw TCP probe on 2775: SMPPSim reads an empty connection as a malformed PDU and logs a
# full stack trace per attempt: with DECODE_PDUS_IN_LOG and a 1s interval that fills the disk
# within tens of minutes (24GB+ across the 9 services in-house). The HTTP admin port answers a
# bare request harmlessly.
test: ["CMD-SHELL", "curl -sf -o /dev/null http://127.0.0.1:8884/"]
interval: 1s
retries: 30
timeout: 2s
# A second line of defence for the same runaway-logging risk: DECODE_PDUS_IN_LOG dumps every PDU,
# so a stuck reconnect loop or a bad probe fills the disk long before anyone notices.
x-log-limits: &log-limits
logging:
driver: json-file
options:
max-file: "3"
max-size: 20m
services:
smppsim:
build:
context: interop-tests/peers/smppsim
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim.props"]
healthcheck: *smppsim-healthcheck
smppsim-textdlr:
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim-textdlr.props"]
healthcheck: *smppsim-healthcheck
smppsim-transition:
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim-transition.props"]
healthcheck: *smppsim-healthcheck
smppsim-undeliv:
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim-undeliv.props"]
healthcheck: *smppsim-healthcheck
smppsim-rejected:
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim-rejected.props"]
healthcheck: *smppsim-healthcheck
smppsim-accepted:
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim-accepted.props"]
healthcheck: *smppsim-healthcheck
smppsim-delayed:
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim-delayed.props"]
healthcheck: *smppsim-healthcheck
smppsim-queuefull:
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim-queuefull.props"]
healthcheck: *smppsim-healthcheck
# OUTBIND_ESME_IP_ADDRESS in smppsim-outbind.props names the node service by its --use-aliases
# hostname; our own server() in smppsim.test.ts listens on port 2776 for it to connect to.
smppsim-outbind:
image: larvitsmpp-interop/smppsim:bc29982
<<: *log-limits
command: ["conf/smppsim-outbind.props"]
healthcheck: *smppsim-healthcheck
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:smppsim"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
smppsim:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smppsim.pcapng"]
# dumpcap writes the pcapng section header as soon as it opens the interface, before any
# packet arrives, so a non-empty file means the capture is actually running.
healthcheck:
test: ["CMD-SHELL", "test -s /captures/smppsim.pcapng"]
interval: 1s
retries: 30
timeout: 2s
volumes:
- ./interop-tests/captures:/captures
# A second sidecar, cheap to add, so a textdlr-specific wire question can be checked without a
# second run. Not read by run.py's own pass/fail (that only decodes captures/smppsim.pcapng).
capture-textdlr:
image: nicolaka/netshoot:v0.16
network_mode: "service:smppsim-textdlr"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
smppsim-textdlr:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smppsim-textdlr.pcapng"]
healthcheck:
test: ["CMD-SHELL", "test -s /captures/smppsim-textdlr.pcapng"]
interval: 1s
retries: 30
timeout: 2s
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_healthy
capture-textdlr:
condition: service_healthy
smppsim:
condition: service_healthy
smppsim-textdlr:
condition: service_healthy
smppsim-transition:
condition: service_healthy
smppsim-undeliv:
condition: service_healthy
smppsim-rejected:
condition: service_healthy
smppsim-accepted:
condition: service_healthy
smppsim-delayed:
condition: service_healthy
smppsim-queuefull:
condition: service_healthy
smppsim-outbind:
condition: service_healthy
-45
View File
@@ -1,45 +0,0 @@
services:
smscsim:
image: ukarim/smscsim:0.2.0
environment:
SMSC_PORT: "2775"
WEB_PORT: "12775"
healthcheck:
test: ["CMD-SHELL", "netstat -lnt | grep -q :2775"]
interval: 1s
retries: 30
timeout: 2s
smscsim-failing:
image: ukarim/smscsim:0.2.0
environment:
FAILED_SUBMITS: "true"
SMSC_PORT: "2775"
WEB_PORT: "12775"
healthcheck:
test: ["CMD-SHELL", "netstat -lnt | grep -q :2775"]
interval: 1s
retries: 30
timeout: 2s
capture:
image: nicolaka/netshoot:v0.16
network_mode: "service:smscsim"
cap_add:
- NET_ADMIN
- NET_RAW
depends_on:
smscsim:
condition: service_started
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smscsim.pcapng"]
volumes:
- ./interop-tests/captures:/captures
node:
depends_on:
capture:
condition: service_started
smscsim:
condition: service_healthy
smscsim-failing:
condition: service_healthy
-320
View File
@@ -1,320 +0,0 @@
import assert from 'node:assert/strict';
import test, { after, describe } from 'node:test';
import type { Session } from '../src/session/session.ts';
import type { Sms } from '../src/session/sms.ts';
import type { LogMethod, SmppLog } from '../src/log.ts';
import { server } from '../src/server/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, options.ts defaults.maxHeldMessages), the bound past which a
// peer's window is answered ESME_RTHROTTLED. smpp-dumb-client counts a throttled message as sent
// and never resends it, so window 2000 accounts for 20,000 as answered plus throttled.
const throttleMessage = 'session - unanswered messages at their bound, asking the peer to retry';
// window500's peak (<=500) and the soak's never reach the 1000 default, so every refusal is
// necessarily from the w2000 session - the runs share one server and one log.
function throttled(name: string): number {
return name === 'dumb-w2000' ? logEntries.filter(entry => entry.message === throttleMessage).length : 0;
}
describe('S9 - bounded window against a slowed handler', () => {
for (const name of ['dumb-w500', 'dumb-w2000'] as const) {
test(`${name}: every message answered or throttled exactly once, ordering holds`, async () => {
const done = await waitFor(() => (statsFor(name).answered + throttled(name) >= 20_000 ? true : undefined), 180_000);
assert.ok(done, `${name} did not account for 20000 messages within budget`);
const s = statsFor(name);
const expectedCount = 20_000 - throttled(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 peer is throttled, window500 never is', () => {
assert.ok(throttled('dumb-w2000') > 0, 'expected at least one ESME_RTHROTTLED under window 2000');
assert.ok(statsFor('dumb-w2000').peakOutstanding <= 1000);
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 (session/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, and asserting that
// arrived/answered stay in lockstep.
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);
});
});
-138
View File
@@ -1,138 +0,0 @@
# 01 smscsim
Date: 2026-09-05. Repo commit: `7d855cf` (working tree, phase 0+1 changes uncommitted on top).
Host Docker: 29.6.2. Images: `ukarim/smscsim:0.2.0` (peer, both `smscsim` and `smscsim-failing`),
`nicolaka/netshoot:v0.16` (capture sidecar and tshark), `node:24.18.0-bookworm-slim` (test runner,
from the root `compose.yaml`).
## Setup
Worked as designed: `interop-tests/run.py smscsim` brings up `smscsim`, `smscsim-failing` and
`capture` via `interop-tests/compose.smscsim.yaml`, waits on their healthchecks (`netstat -lnt |
grep -q :2775`, both images have a busybox shell), runs `interop-tests/smscsim.test.ts` in the
`node` service, stops the capture, decodes it with tshark, and tears down.
Two snags fixed while building the harness, both in `run.py`/the compose overlay, not the peer:
- `dumpcap`'s binary is mode `0750` root:root inside `nicolaka/netshoot:v0.16`, so the `capture`
service has to run as root (the default) rather than `1000:1000` - matching the "otherwise fix
ownership from run.py" fallback the brief anticipated. `run.py` chowns and chmods
`interop-tests/captures/` to `1000:1000`/`0777` through a throwaway container after every run.
- This sandbox's Docker does not give a root container DAC-override: it can create a new file in a
`1000:1000`-owned `0777` directory, but not overwrite an existing `1000:1000`-owned file there
(dumpcap's own file mode, `0600`, blocks it). `run.py` now unlinks the previous
`<peer>.pcapng` itself before every run, so `dumpcap` always creates a fresh file.
- The research notes and PLAN.md's knobs column say `FAILED_SUBMITS=1`; `main.go` actually checks
`"true" == os.Getenv("FAILED_SUBMITS")`, so `1` is silently ignored (never fails anything). The
compose overlay sets `FAILED_SUBMITS: "true"`.
Two runs of `./interop-tests/run.py smscsim`, back to back, both exit 0:
```
frames: 114
commands:
bind_receiver: 1 bind_receiver_resp: 1
bind_transceiver: 12 bind_transceiver_resp: 12
bind_transmitter: 1 bind_transmitter_resp: 1
deliver_sm: 20 deliver_sm_resp: 10
enquire_link: 6 enquire_link_resp: 6
submit_sm: 19 submit_sm_resp: 19
unbind: 3 unbind_resp: 3
malformed: 0
expert errors: 0
```
(identical both times). `bind_transceiver` is 12, not the 5 a reconnect-free run would show (C1's
one transceiver bind + single-SMS + GSM-multipart + UCS2-multipart + MO, one each) - the extra 7
are the client's own reconnects after the defect below tears the link down; `deliver_sm_resp` is
half of `deliver_sm` for the same reason (below).
## Scenarios
| Id (from PLAN.md) | Result | Evidence |
| --- | --- | --- |
| C1 (bind transceiver/transmitter/receiver, keepalive, clean unbind) | pass | `smscsim - C1 bind, keepalive, unbind`, all 3 bind types; no `sessionError`, one `close` each |
| smoke: single SMS + DLR | pass | `smscsim - a single SMS`; DLR `statusMsg` `DELIVERED`, `smsId` matches the `submit_sm_resp` id |
| smoke: 2-segment GSM long MT | pass | `smscsim - multipart segments › a 2-segment GSM message…`; 2 ids, 2 DLRs (via retry - see defect) |
| smoke: 2-segment UCS2 long MT (一 + emoji) | pass | `smscsim - multipart segments › a 2-segment UCS2 message…`; 2 ids, 2 DLRs (via retry) |
| smoke: MO injection via web UI | pass | `smscsim - MO injection…`; `sms.from`/`to`/`message` match the posted form, `sendResp()` clean |
| C12 (smscsim part: refusal + undeliverable DLR) | pass | `smscsim-failing - C12 refusals`; refused sends name `ESME_RSYSERR`, accepted ones' DLRs name `UNDELIVERABLE`; session stayed bound throughout (`enquire_link` answered after) |
Every scenario passed both runs, but the multipart, single-SMS and MO scenarios only pass because
they retry past the defect below (`DLR_MAX_ATTEMPTS = 20` in `smscsim.test.ts`) - see Defects.
## Defects in @larvit/smpp
### An out-of-range `deliver_sm` sequence_number drops the whole link, not just that PDU
**What happened.** `smscsim` signs every `deliver_sm` it sends unprompted - a delivery receipt or
an injected MO - with a raw `rand.Int()` truncated to `uint32` for `sequence_number`
(`smsc.go`'s `deliverSmPDU`, called from both `deliveryReceiptPDU` and `SendMoMessage`), so about
half the time the value is `>= 0x80000000`. `pdu.ts`'s `parseOnce` rejects that with `Invalid
seqNr, exceeds 2147483646: <n>`, and `pdu-transport.ts`'s `read()` routes *every* `pduToObj` error -
this one included - to `onUnreadable`, which `session.ts` wires to `sessionError` +
`teardown()`. `teardown()` destroys the socket outright; with the client's default `reconnect: true`
the session then reconnects (invisibly to the caller: `sendSms()` on a mid-reconnect session just
queues until the new link is bound), but the `deliver_sm` that triggered it - and its answer, since
none is ever sent - are gone. Confirmed live: binding, then sending a 2-segment message with `dlr:
true` against a real `smscsim`, printed `SESSION ERROR Invalid seqNr, exceeds 2147483646:
4085734660` for the second segment's receipt, no `dlr` event fired for it, and the capture showed
the peer's two `deliver_sm` PDUs answered by only one `deliver_sm_resp`.
**What the spec says.** SMPP 3.4 §4.7.1: `sequence_number` is `0x00000001` to `0x7FFFFFFF`; a
value outside it is certainly not a request this library ever intends to send and arguably not
one it must answer either. But target 1 in PLAN.md is exactly this shape: "`pdu-transport.ts`
routes every codec error... to the teardown a framing error takes, although `command_length` was
honoured and the stream is still in sync." Here `command_length` is honoured, the command is
`deliver_sm`, and only one 4-byte field is out of range - the spec gives no status for "sequence
number out of range" specifically, but continuing to read the stream and refusing just this PDU
(there is no `*_resp` to send back without a valid sequence number to answer with; a `generic_nack`
naming e.g. `ESME_RINVCMDID` would need a sequence number too, which is presumably part of why the
current code gives up on the whole link) would lose one receipt instead of the link.
**Reproducer.** A minimal `deliver_sm` with every field empty/zero except the header:
```
000000210000000500000000800000010000000000000000000000000000000000
```
(33 bytes: `command_length=0x21`, `command_id=0x00000005` deliver_sm, `command_status=0`,
`sequence_number=0x80000001`, then 17 zero bytes for `service_type`..`short_message` each
empty/0.) Feeding this to `pduToObj` (`src/pdu.ts`) returns `{ err: Error("Invalid seqNr, exceeds
2147483646: 2147483649") }`; feeding it to a live session's socket reproduces the teardown.
**Severity.** Medium-high against this peer specifically: roughly half of `smscsim`'s DLRs and MOs
are silently lost and bounce the link. Against a spec-conforming peer (small incrementing sequence
numbers) it never fires, so it is plausibly why the suite's own dummy peers never caught it - which
is the whole reason this experiment exists.
**Fixed** in PR #79: only a framing error tears the link down now, any 32-bit `sequence_number` is
read and echoed, and `smscsim.test.ts`'s retry crutch is gone. A rerun of `./interop-tests/run.py
smscsim` shows 54 frames, `deliver_sm: 6` answered by `deliver_sm_resp: 6`, `bind_transceiver: 5`
(no reconnects), `malformed: 0`, `expert errors: 0`, 8/8 tests passing on their first attempt.
## Peer quirks
- No PDU validation (documented): a bad `interface_version` or malformed PDU is never rejected.
- `FAILED_SUBMITS` needs the literal string `true`; PLAN.md's research notes say `1`, which the
peer silently ignores (see Setup).
- DLR is always exactly `DELIVERED` (or, with `FAILED_SUBMITS=true`, `UNDELIVERABLE` on odd
sequence numbers) after a fixed ~2s; no other status is reachable.
- `FAILED_SUBMITS=true` refuses only `submit_sm`s whose *own* sequence number is even
(`ESME_RSYSERR`); it does not otherwise vary behaviour, and the DLR-triggering rule above applies
to every accepted submit regardless of parity.
- MO injection (the `12775` web page) always encodes the message as UCS2 (`data_coding=8`)
regardless of its content, and requires an already-bound session whose `system_id` matches the
form's `system_id` field exactly (`sender`, `recipient`, `message`, `system_id`, `POST /`,
`web.go`'s `webHandler`); the response is a `303` redirect to `/?message=...` (or `?error=...`).
- Message ids and `deliver_sm` sequence numbers are `rand.Int()`-derived per-process, not reset or
seeded per connection - not proven security-relevant here, but they are not unique across a
restarted container in the way a UUID would be.
## Open questions
- Whether the same out-of-range-sequence-number shape reaches other peers (Jasmin, SMPPSim) or is
particular to `smscsim`'s unconstrained `rand.Int()` - phase 2+ should watch for the same
`sessionError` text.
- Per PLAN.md's Order of work, this defect should get a regression test in `test/` and a fix before
phase 2 starts; both are out of scope for this experiment (`src/`/`test/` are read-only here).
-210
View File
@@ -1,210 +0,0 @@
# 02 smppsim
Date: 2026-09-05. Repo commit: `9c4939f`. Host Docker: 29.6.2. Images: `larvitsmpp-interop/smppsim:bc29982`
(built locally here from `kwahome/smpp-sim-docker` at commit `bc299828af9046ab290da3b4957dfbc472f02bfb`,
running SMPPSim 2.6.11 on `eclipse-temurin:8u452-b09-jre`; cloned during the build with
`debian:13.2-slim`, never vendored), `nicolaka/netshoot:v0.16` (capture sidecars and tshark),
`node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`).
## Setup
`interop-tests/peers/smppsim/Dockerfile` clones the pinned commit in a build stage and copies just
`smppsim.jar`, `lib`, `www`, `mo` and `conf/logging.properties` into the runtime stage; each
`smppsim*.props` file under the same directory is copied in and selected via the container
`command`. `interop-tests/compose.smppsim.yaml` runs nine variants of the one image (`smppsim`,
`-textdlr`, `-transition`, `-undeliv`, `-rejected`, `-accepted`, `-delayed`, `-queuefull`,
`-outbind`) plus `capture` (on `smppsim`) and `capture-textdlr`, all on one network so
`./interop-tests/run.py smppsim` covers everything in one run.
Two snags fixed while building the harness:
- **The first healthcheck (`bash -c 'echo > /dev/tcp/127.0.0.1/2775'`, 1s interval) filled the
host's disk.** SMPPSim reads an empty connection as a malformed PDU and logs a full Java stack
trace per attempt; with `DECODE_PDUS_IN_LOG=true` and a 1s probe interval, nine containers left
running for ~20 minutes wrote 24GB+ of container logs between them and took the whole shared host
to 0 bytes free (`docker run` itself started failing with "no space left on device"). Fixed by
probing the HTTP admin port instead (`curl -sf -o /dev/null http://127.0.0.1:8884/`), which
SMPPSim answers harmlessly, plus a `json-file` log cap (`max-size: 20m`, `max-file: "3"`) on every
`smppsim*` service as a second line of defence. Worth knowing for anyone else pointing a 1s
TCP-connect healthcheck at this peer.
- The `capture` sidecar's `dumpcap` needs the same root/DAC-override handling phase 1 documented
(`interop-tests/captures/` chowned to `1000:1000`/`0777` by `run.py` after every run, stale
`<peer>.pcapng` unlinked before the next). Once, a capture attempt failed outright
("Permission denied" opening the pcapng) for a reason not pinned down - not reproduced since;
treat as a rare, unexplained flake in this harness rather than a peer issue.
- Two early runs showed `malformed: 2`, `expert errors: 2`. Both traced to this test file, not
SMPPSim: C17's raw-UDH test used IEI `0x01` ("Special SMS Message Indication"), a real GSM 03.40
information element with its own defined length (2 bytes), at length 1 - Wireshark's
`gsm_sms_ud` dissector correctly flags that as malformed. Fixed by using IEI `0x70`
(reserved-for-future-use, so no dissector validates its length) for what the test only ever
needed to be an arbitrary, unparsed UDH element.
Two runs of `./interop-tests/run.py smppsim`, back to back, after that fix: both exit 0, 25/25
tests passing, `malformed: 0`, `expert errors: 0` in both.
## Scenarios
| Id (from PLAN.md) | Result | Evidence |
| --- | --- | --- |
| C2 (`smppsim-textdlr`) | pass | `smppsim-textdlr - C2 text-only receipts`; no TLVs, `dlr.smsId` matches `submit_sm_resp` |
| C3 (`smppsim`, TLVs on) | pass | `smppsim - C3+C7 …`; `receipted_message_id`/`message_state` TLVs present and agree with the body on every segment |
| C4 (`smppsim-transition`) | fail (peer defect, see below) | `smppsim-transition - C4 …`; intermediate report arrives, no final ever does |
| C5 (single-state variants) | pass | `smppsim single-state variants - C5 …`; UNDELIVERABLE/REJECTED/ACCEPTED each map correctly; 2-segment message's shared status confirmed, `messageDlr` confirmed absent (see Open questions) |
| C6 (`smppsim-delayed`) | pass | `smppsim-delayed - C6 …`; disconnect+reconnect observed, delayed receipt still reaches `dlr` on the new link |
| C7 (loopback, GSM 1/2/3/10-segment) | pass | same `C3+C7` suite; one id per segment, receipt per id, loopback reassembles the exact text including € and \[ \] |
| C7 (loopback, UCS2 2-segment) | pass with a defect, since fixed | `… 2-segment UCS2 …`; TLVs and loopback reassembly both correct, receipt body unreadable at this commit - `@larvit/smpp` defect below |
| C11 (bind refusal + backoff) | pass | `smppsim - C11 …`, three sub-tests, see below for what each shows |
| C12 (`smppsim-queuefull`) | pass | `smppsim-queuefull - C12 …`; `ESME_RMSGQFUL` returned, session stays bound, later send succeeds once the one-slot queue drains |
| C13 (`maxOutstanding: 1`, 10 parallel) | pass | `smppsim - C13 …`; 10 distinct, strictly-increasing ids, none lost |
| C15 (bind version) | pass (peer never declares, see below) | `smppsim - C15 …`, both 0x34 and 0x50 |
| C17 (encodings over loopback) | pass | `smppsim - C17 …`, 5 sub-tests: Latin-1, UCS-2, flash (0x10), raw 0xF0 (flash), raw UDH+8-bit-binary |
| C18 (`smppsim-outbind`) | pass (record, not judge) | `smppsim-outbind - C18 …`; see below for the wire facts |
C17's 0xF0 sub-test originally read `not flash`, recording a `@larvit/smpp` defect: 0xF0 is GSM
03.38 message class 0, immediate display. Fixed in
[#95](https://github.com/larvit/larvitsmpp/pull/95), and the assertion inverted in `ff6ba9f`.
## Defects in @larvit/smpp
### A delivery receipt's `data_coding` is trusted to decode its body, even though the spec makes the receipt a fixed text format
**What happened.** A message sent with `encoding: 'UCS2'` gets `data_coding: 8` on its `submit_sm`.
SMPPSim's delivery receipt for it (confirmed against source, `DeliveryReceipt` extends `DeliverSM`
by copy-constructing the original `SubmitSM`) inherits that same `data_coding: 8`, but its
`short_message` is always plain ASCII text (`id:… sub:… dlvrd:… stat:…`). `pdu.ts`'s parser decodes
any non-UDH PDU's `short_message` using its own `data_coding` unconditionally
(`params.short_message = decodeMessage(message, paramNumber(params.data_coding, 0)).message`), so
the ASCII receipt bytes are read back as UCS2 (byte pairs swapped) - `id:001 sub:…` becomes
unrecoverable CJK-range glyphs, and `dlr.receipt` (hence `.stat`, `.id`, every body field) is
garbage or `undefined` for every receipt whose triggering message used a non-ASCII encoding.
**What the spec says.** SMPP 3.4 5.2.25 defines `short_message`/`message_payload` generically, but
Appendix B's receipt format is specified as fixed ASCII fields; `data_coding` on the *receipt* PDU
describes nothing about how to read that text, since the MC is reporting on a message rather than
carrying one. Trusting `data_coding` to decode a receipt body is reading a field the spec never
attaches that meaning to. The `receipted_message_id`/`message_state` TLVs are unaffected (typed
fields, not text), which is why `dlr.statusMsg` and `dlr.smsId` stay correct here - only the
body-derived `dlr.receipt` (and anything relying on it, e.g. a peer without TLVs) is lost.
**Reproducer.** `session.sendSms({ encoding: 'UCS2', dlr: true, message: 'x', from, to })` against
`smppsim`, then inspect the `dlr` event's second argument (the raw `PduObject`):
`pduObj.params.data_coding === 8` and `dlr.receipt === undefined`, while
`pduObj.tlvs.receipted_message_id`/`message_state` are present and correct. Confirmed both against
SMPPSim and by constructing the PDU directly: a `deliver_sm` with `data_coding=8`, `esm_class=4`
and an ASCII `id:0 sub:001 dlvrd:001 …` body decodes to garbage text via `pduToObj`.
**Severity.** Medium: harmless when TLVs are present (as here), since `statusMsg`/`smsId` still
resolve correctly - but total for a peer that answers `DELIVERY_RECEIPT_OPTIONAL_PARAMS=false`
(text-only, `smppsim-textdlr`'s own style) *and* accepts non-ASCII submits, where nothing would be
left to fall back on. Not reproduced against `smppsim-textdlr` here (that variant's own C2 test
only sends ASCII), so this is inferred from the mechanism, not independently confirmed there - see
Open questions.
**Fixed** in PR #81: a receipt body is read as the octets that arrived, never by its `data_coding`.
### `reconnect` never retries the very first connect or bind attempt
**What happened.** `client()` performs the socket connect and the initial bind directly, not
through the `ReconnectLoop`; on either failing (`ESME_RINVPASWD`, `ESME_RBINDFAIL`, connection
refused, …) it calls `session.close()` and returns `{ err }` with no session at all - the
`reconnect` option is never consulted. Confirmed with a wrong password and with a closed port
against `smppsim` (`smppsim - C11 …`, both "one attempt, no retry" sub-tests): each is exactly one
bind attempt, logged once, no matter how long the test then waits. `ReconnectLoop.schedule()` only
runs from `onClose()`/a failed `comeBackUp()` after a session has been up at least once - confirmed
separately by binding once, then mutating the same `options` object's `password` before dropping
the live link: the loop *does* retry with proper backoff then (`smppsim - C11 …, "a rebind refused
after a live link drops"`: 2-6 attempts over 12s, each gap ≥150ms).
**What the spec/README say.** Neither documents this boundary explicitly; `README.md`'s `reconnect`
entry reads as covering any drop uniformly. Given hard rule/goal 4 ("no bind flooding"), never
retrying a cold failure is arguably the safer default - a bad password retried forever would flood
exactly as much as this avoids it entirely - but it means a client started before its peer's
listener is up gets one shot and gives up for good, which a caller relying on `reconnect` for that
race would not expect.
**Reproducer.** `client({ host, password: 'wrong', reconnect: { minDelay: 1000, maxDelay: 4000 } })`
against `smppsim` (valid `system_id`, wrong `password`): resolves `{ err }` once, no `session`; a
`log.info` capture shows exactly one `'client - bind refused'` line even after a further 2s wait.
**Severity.** Low / informational - plausibly intentional, not previously written down anywhere
`git grep`-able in this repo.
## Peer quirks
- **`registered_delivery` 0x11 (final + intermediate together) never produces a final receipt.**
SMPPSim's own user guide (v2.5 release notes) says "Set to 0x11 for both intermediate
notification and final delivery receipts." `LifeCycleManager.setState()` tests
`registered_delivery_flag == 1` or `== 2` by exact integer equality rather than a bitmask
(`InboundQueue.addMessageState()`'s own intermediate-notification check correctly uses
`(rd & 0x10) == 0x10`), so 17 (0x11) matches neither branch: the intermediate report (esm_class
0x20, `ENROUTE`) always arrives, a final one never does, however long you wait. Confirmed by
reading `LifeCycleManager.java`, `MessageState.java` and `OutboundQueue.java`, and empirically
with a 16s wait against `smppsim-transition`.
- **Two `submit_sm`'s on the same connection without a response in between - exactly how this
library always sends a multi-segment message's segments (`Promise.all`, never one after the
previous one's response, a documented, deliberate design choice, not something to change here) -
sometimes make SMPPSim lose one of the resulting receipts or loopback echoes entirely.** Never
seen as a malformed/corrupted PDU on the wire (both kept runs, and every other run once the
harness's own UDH bug above was fixed, show zero); the PDU that should have arrived just never
does. Confirmed by direct A/B: a script sending two segments concurrently (this library's own
shape) got only one of two receipts about half the time across several tries; the same script
sending two independent `submit_sm`'s sequentially, awaiting each response before the next, never
lost one in the same number of tries. `interop-tests/smppsim.test.ts`'s own multi-segment tests
work around it by resending a fresh message (up to 10 times) until every segment's receipt and,
where relevant, the loopback reassembly are all present in one attempt - see
`sendUntilAllDlrsArrive`/`sendUntilComplete`/`dlrLooksIntact` there. The mechanism is unconfirmed
(see Open questions).
- **`bind_resp` never carries `sc_interface_version`, whatever the ESME declared.** No
`BindTransceiverResp`/`BindReceiverResp`/`BindTransmitterResp` class sets that TLV (confirmed
against source). `@larvit/smpp`'s client therefore always records
`peerInterfaceVersion === 0x00` (undeclared) and `acceptsOptionalParams() === false` against this
peer, whether the client declared 0x34 or 0x50 - yet `smppsim` (with
`DELIVERY_RECEIPT_OPTIONAL_PARAMS=true`) still sends `receipted_message_id`/`message_state` TLVs
on every receipt regardless, because that gate reads the *client's own declared* version from the
bind PDU it received, not anything it echoes back. Confirmed for both 0x34 and 0x50 (`smppsim -
C15 …`).
- **`DelayedDrQueue`'s own poll loop is hardcoded to 5000ms** (`private static final int period =
5000;`, not a props knob - `DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD` is a different queue, for
redelivery after `ESME_RMSGQFUL`). `DELAY_DELIVERY_RECEIPTS_BY` is a floor, not the delay: a
receipt configured for an 8000ms delay can take up to ~13000ms in practice. Confirmed by direct
measurement (an 11s wait saw nothing; 16s did).
- **Message ids are decimal, a per-process global counter** (`message_id++`, not per-connection),
starting at 0 unless `START_MESSAGE_ID_AT`/`MESSAGE_ID_PREFIX` are set (neither is here) - matches
the research notes; `submit_sm_resp` and every receipt spelling (TLV and body `id:`) agree, so no
`smsIdFormat` was needed anywhere in this suite.
- **The receipt body's echoed-message field is `Text:` (capitalised, not `text:`)** and carries up
to the first 20 bytes of the original message when it is non-empty - never the empty `text:` some
other peers (documented: LINK) send. `@larvit/smpp`'s parsing is already case-insensitive, so this
needed no special handling.
- **`outbind()` closes the socket immediately after writing, without reading anything back.**
Confirmed against source (`Smsc.outbind()`: `out.write(...); out.flush(); out.close(); s.close();`
with no read in between) and on the wire (`smppsim-outbind - C18 …`): our server's
`incomingPduObj` sees the `outbind` (`system_id: smppclient1`), `onRequest` tries to answer
`ESME_RINVBNDSTS` and fails before writing anything (`outbind_resp` is not a defined command, so
`pduReturn` errors first) - `sessionError` fires with `"outbind" has no response command`, no PDU
reaches the wire, matching target 6. Whether our socket sees SMPPSim's own close land as a clean
`close` was observed but not asserted against, per the task ("record, do not judge").
- **`outbind` fires the moment SMPPSim has an MO to deliver and no receiver is bound** - not on a
timer. `InboundQueue.processQueue()`'s wait/notify wakes on `iq.addMessage(...)`, and moves
straight to `PENDING_QUEUE` + `outbind()` when `getReceiverBoundCount() == 0`; the MO Injection
endpoint (`GET /inject_mo?source_addr=…&destination_addr=…&short_message=…`, not `/inject_mo.htm`
as the docs might suggest - that path is the *form*, `/inject_mo` is the handler) works with zero
ESMEs ever bound, which is what let this test trigger `outbind` deterministically instead of
racing `DELIVERY_MESSAGES_PER_MINUTE` against container startup.
## Open questions
- Whether the `data_coding`-decodes-the-receipt-body defect also loses receipts against a peer
answering `DELIVERY_RECEIPT_OPTIONAL_PARAMS=false` (no TLV fallback) - `smppsim-textdlr`'s own
scenario here only exercises an ASCII message, so the compounding case (non-ASCII + no TLVs) is
untested.
- The exact mechanism behind the occasional lost receipt/loopback echo under concurrent
`submit_sm`'s. A plausible read of SMPPSim's threading (the connection-handler thread writing
`submit_sm_resp`s and loopback echoes, a separate `OutboundQueue`/`InboundQueue` thread writing
receipts, both against the same socket with no synchronisation found in
`StandardConnectionHandler`) would predict corruption, not a clean loss - but no malformed PDU
was ever observed for a genuine SMPPSim-authored frame in this suite, so that read is unconfirmed
and the actual mechanism (dropped at the SMPPSim side before it ever reaches the wire, versus
something on our side discarding a well-formed PDU) is still open.
- Phase 2's own instructions call for `smppsim-accepted`/`-rejected` as "similar single-state
variants... if cheap"; both were cheap and are included, alongside `-undeliv`.
-249
View File
@@ -1,249 +0,0 @@
# 03 jasmin
Date: 2026-09-06. Repo commit: `db02f00`. Host Docker: 29.6.2. Images: `jookies/jasmin:0.11.0`
(both directions), `redis:8.8.2-alpine`, `rabbitmq:3.13.7-management-alpine` (fallback - see
below), `python:3.13.7-slim-bookworm` (jcli bootstrap), `nicolaka/netshoot:v0.16` (capture),
`node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`).
## Setup
Two full Jasmin instances (`jasmin`, `jasmin-datasm`), each with its own `redis`/`rabbitmq` pair -
kept separate rather than shared, since two unrelated Jasmin instances sharing a broker is the
kind of proximity-only coupling the project avoids. `jasmin-datasm` mounts a custom `jasmin.cfg`
(`[dlr-thrower] dlr_pdu = data_sm`) for C9/target 4. Both run a `jcli` bootstrap
(`interop-tests/peers/jasmin/bootstrap.py`, a plain socket client - no telnet negotiation reply is
needed, the server proceeds regardless) that creates a group, two users (`esme1` for most
scenarios, `esme2` with a throttled `smpps_throughput` quota for C12), an `smppccm` connector
("upstream"/"upstreamds") pointing at our own `node` container, and `mtrouter`/`morouter`
`DefaultRoute`s wiring MT to that connector and MO back to `smpps(esme1)`. `interop-tests/jasmin.test.ts`
runs one persistent `server()` on `node:2777` as the fake real-world SMSC both connectors bind out
to, plus a tiny HTTP listener for Jasmin's DLR-thrower webhook (S7).
**RabbitMQ 4.3.5 does not work with Jasmin 0.11.0**: its `txamqp` client declares
transient/non-exclusive queues, a feature RabbitMQ 4.x refuses by default ("transient_nonexcl_queues
... not permitted anymore"), so `RouterPB`/`DLRThrower` fail to start. Fell back to the 3.13 series;
`rabbitmq:3.13.7-management-alpine` starts clean.
Snags fixed while building the harness, roughly in the order found:
- **jcli commands after `smppccm -a` need loud failure detection, not keyword-sniffing.** A failed
`ok` ("Failed adding connector, check log for details") doesn't contain any of "error"/"unknown"/
"invalid" - it left the bootstrap's own session stuck at the `>` sub-prompt, and every later
command was misread as a key inside it. Fixed by also checking the last reply lands back on the
top-level `jcli :` prompt.
- **The connector's password has an 8-character wire maximum.** SMPP's `password` is a C-octet-string
with an 8-char + NUL limit; Jasmin's own `smpp.pdu` encoder enforces it strictly when it builds the
connector's own bind PDU (our library's encoder is permissive and doesn't). A 10-char password
made every single connector bind throw mid-encode (`ValueError: COctetString is longer than
allowed maximum size (9)`), logged only in the connector's own per-CID log file
(`/var/log/jasmin/default-<cid>.log`), not in the main Jasmin process log.
- **`session.userData` is unset when the `session` event fires** (it fires on raw connect, before
`authenticate()` runs) - populating a map off it there silently never worked. Fixed like
`kannel.test.ts`'s `bindPdus`: read the bind PDU's own `system_id` from `incomingPduObj` instead,
and only read `userData` later, from `sms`, once authenticate() has long since run.
- **Jasmin's own MT dispatch to one connector is strictly serialized** (see the defect below) -
moving `C13`/`S7`'s HTTP-send test ahead of `C3+C7`'s multi-segment sends in file order (both need
the same connector's queue to be unstuck) turned three tests that always failed into two that
always pass.
- The `jasmin`/`jasmin-datasm` healthcheck probes the HTTP API port (1401) with a bare TCP connect,
no bytes written - Jasmin's SMPP codec, like SMPPSim's, is not something to probe with an empty
PDU. `rabbitmq` uses `rabbitmq-diagnostics -q ping`.
- Bootstrap and the whole `docker compose up` sequence are slow and variable on this host - jcli's
own connector/router commands (which round-trip through AMQP/redis, unlike the plain in-memory
group/user commands) sometimes took most of a minute each under contention; the harness itself
budgets for it (`run.py` runs past its own foreground tool timeout and is watched to completion),
not something to read as a Jasmin defect.
Three runs of `./interop-tests/run.py jasmin`, all after the fixes above: all exit non-zero (the
4 failing tests below), all `malformed: 0`, `expert errors: 0`. Wire commands, from the last run:
Re-run 2026-09-06, after every defect below was fixed and after `run.py` learned to refuse a capture
holding no frames: exit 0, 19 of 19 tests pass, 175 frames, `malformed: 0`, `expert errors: 0`. The
four multi-segment failures are gone with the deadlock, and the counts below are the state that
found the defects, kept because that is what the reproducers refer to.
```
bind_transceiver: 14, bind_transmitter: 1, bind_receiver: 6 (+ their _resp)
submit_sm: 45, submit_sm_resp: 43
deliver_sm: 15, deliver_sm_resp: 1
enquire_link: 7, enquire_link_resp: 7
unbind: 1, unbind_resp: 1
```
## Scenarios
| Id | Result | Evidence |
| --- | --- | --- |
| C1 | pass | `binds transceiver, sees Jasmin's own enquire_link, unbinds clean`; `binds transmitter`; `binds receiver` |
| C3+C7 (single-segment) | pass | `single-segment GSM with extension chars`: UUID id, `receipted_message_id`/`message_state` TLVs present, `DELIVERED` |
| C3+C7 (multi-segment) | **fail (peer/library interaction, see Defects)** | `2-segment`/`3-segment`/`10-segment GSM`, `2-segment UCS2`: every attempt across 3 runs times out waiting for a receipt |
| C8 target 3 (SAR) | pass | `SAR-segmented deliver_sm from the fake upstream`: reassembles into one whole `sms` |
| C8 target 3 (UDH) | pass | `UDH-segmented deliver_sm from the fake upstream`: reassembles into one whole `sms` |
| C8 target 2 (`message_payload`) | pass (defect confirmed) | `a deliver_sm carrying message_payload instead of short_message`: Jasmin accepts and relays it; our `sms` arrives with an empty message - see Defects |
| C9 target 4 (`data_sm` DLR) | pass (defect confirmed) | `a receipt thrown as data_sm is not read as a dlr`: the raw `data_sm` PDU is seen, no `dlr` event ever fires - see Defects |
| C11 | pass | `wrong password: one attempt, no retry` (`ESME_RINVPASWD`); `a rebind refused after a live link drops: backs off, never floods` (2-8 attempts, ≥150ms apart) |
| C12 | pass | `flooding submits past the quota`: `ESME_RTHROTTLED` from `esme2`'s `smpps_throughput 0.1` quota on some of 15 parallel sends; session stays bound; a later send succeeds once the next slot opens |
| C13 | pass | `every send is answered, none lost, order preserved`: 10 distinct ids, arrival order matches send order |
| S7 | pass | `a message pushed through /send arrives as submit_sm`: HTTP `/send` → Jasmin → connector → our `server()`; our `sendResp()`+`sendDlr('DELIVERED')` fires Jasmin's own DLR webhook, both the SMSC-ack (`ESME_ROK`) and terminal (`DELIVRD`) callbacks at `dlr-level=3`; long GSM and UCS-2 MO pushes from our server reassemble whole at Jasmin's connector |
## Defects in @larvit/smpp
### `message_payload` is never read (target 2) - confirmed against a real peer
**What happened.** Jasmin's connector accepts a `deliver_sm` with `sm_length` 0 and the text in a
`message_payload` TLV from our fake upstream SMSC without complaint, and relays it through
`morouter` to our real client bound to Jasmin's `smpps`. The `sms` event fires with the right
envelope (`from`/`to`) but `message` is the empty string - the actual text, which only ever existed
in `message_payload`, is lost. Matches the target exactly: `incoming-requests.ts` reads
`short_message` only.
**Reproducer.** `interop-tests/jasmin.test.ts`, `C8 (target 2)`: build a `deliver_sm` with
`short_message: Buffer.alloc(0)` and `tlvs: { message_payload: { tagValue: Buffer.from(text) } }`,
send it over the connector's session, and inspect the `sms` event at a client bound to Jasmin's
`smpps` - `sms.message === ''`.
**Severity.** Medium: silent data loss, not a wire error - the message is fully present on the wire
and Jasmin forwards it faithfully; only this library's read of it is incomplete.
**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): the body is read from
`message_payload` where `short_message` carries none. The relay is confirmed on the wire - the
capture's one `sm_length` 0 `deliver_sm` is Jasmin's own, out of `smpps` to our client, carrying the
`0x0424` TLV intact. The reproducer's own fixture was wrong as well as the library: it built the TLV
as Latin-1 while the PDU declared `data_coding` 0, so `_` (GSM 03.38 0x11, Latin-1 0x5F) came back
as `§` even once the body was read. It now encodes the payload the way the PDU says it is written.
### `sar_*`/UDH segmentation from an upstream SMSC reassembles fine (target 3, MO direction) - not reproduced as a defect here
C8's SAR and UDH MO pushes both reassembled into one whole `sms` at our real client. This does not
confirm target 3 is fixed in general (our own reassembly still keys on UDH only - a SAR-tagged
`deliver_sm` from Jasmin's connector would still arrive as an unrelated fragment if Jasmin ever
sent SAR MO unprompted), it confirms only that pushing SAR/UDH-tagged `deliver_sm`s ourselves,
directly over the connector session, reassembles correctly on the way out through `smpps` - Jasmin
does not re-segment or otherwise disturb an already-short single PDU in transit.
**Fixed** in [#91](https://github.com/larvit/larvitsmpp/pull/91), where a second peer did reproduce
it (`findings/05-java-clients.md`): reassembly now reads the `sar_*` TLVs as well as the UDH. C8's
SAR scenario no longer accepts fragments as an outcome - it asserts one whole `sms` and no segment
arriving on its own - and a rerun is 19/19 with no malformed frame and no expert error.
### `data_sm` is refused (target 4) - confirmed against a real peer
**What happened.** With `jasmin-datasm`'s `[dlr-thrower] dlr_pdu = data_sm`, a receipt requested via
`sendSms({ dlr: true, ... })` throws as a `data_sm` PDU on the client's bind, exactly as documented.
Our client's `incomingPduObj` sees it arrive; no `dlr` event ever fires. The receipt is lost -
`data_sm` falls to the generic unhandled-command path (`ESME_RINVCMDID`), matching the target.
**Reproducer.** `interop-tests/jasmin.test.ts`, `C9`: bind to `jasmin-datasm`, `sendSms({ dlr: true,
... })`, assert an incoming `data_sm` PDU arrives and no `dlr` event ever fires.
**Severity.** Medium: a real, documented Jasmin configuration (`dlr_pdu = data_sm`) that a receipt
depends on silently drops delivery reports, with no error surfaced to the application either.
**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): `data_sm` is classified and
answered exactly as `deliver_sm` is, and the receipt reaches `dlr` naming the id the `submit_sm_resp`
carried, `DELIVERED`. The wire histogram still shows no `data_sm`: `capture` runs
`network_mode: service:jasmin`, so it only ever sees the main instance's namespace, never
`jasmin-datasm`'s.
### A multi-part MT send deadlocks against Jasmin's serialized per-connector relay - a library/peer interaction, not a wire defect
**What happened.** A 2-, 3-, 10-segment GSM or UCS-2 message sent through Jasmin's `smpps` (our real
client → Jasmin → `mtrouter` → the `upstream` connector → our fake upstream) never gets a receipt,
in every one of 3 runs, regardless of segment count or encoding - only the always-unsegmented
single-segment case succeeds. Jasmin's own `messages.log` shows why: `SubmitSmPDU[...] request
timed out through [cid:upstream], message requeued` after its 120s response timeout, and nothing
for the later segments at all (they stay queued behind the stuck one). The mechanism, confirmed by
dumping every `submit_sm` this library's `server()` received as Jasmin's fake upstream: segment 1
arrives intact (`esm_class: 64`, a correct `05 00 03 <ref> <total> <seq>` UDH, matching exactly what
`splitMessage()` itself would produce - Jasmin relays the UDH byte-for-byte) - but segment 2 never
arrives. `src/incoming-requests.ts`'s `onMessage()` answers nothing for an incomplete concatenation
group (`if (whole) this.emitSms(whole);` - a partial group falls through silently); `sms.sendResp()`
only exists once the whole group is in hand, so it answers all segments of a group atomically, in
one call, after the last one arrives. Jasmin, on its side, dispatches to a given connector one
`submit_sm` at a time, waiting for that one's response before sending the next queued message for
the same connector (confirmed indirectly: reordering the test file so `C13`'s 10 *independent*
single-segment sends and `S7`'s HTTP-send run *before* any multi-segment send turned both from
always-failing into always-passing at sub-second speed, while running them *after* a stuck
multi-segment send left them queued behind it for the same reason). Two designs that are each
individually reasonable - Jasmin never advances its connector queue past an unanswered request;
this library never answers part of a concatenated message - deadlock when composed: Jasmin will not
send segment 2 until segment 1 is acked, and this library will not ack segment 1 until segment 2
arrives.
Ruled out before landing on this: RabbitMQ/host CPU contention (real, but the failure is
deterministic across three runs regardless of load - not a timing flake); the connector's own
`submit_throughput` default of 1 msg/s (set to `0`, no change); the fake upstream's `idleTimeout`
dropping the connector mid-flight (widened to 300s, no change - the connector stayed bound
throughout, confirmed in its own log).
**Reproducer.** `interop-tests/jasmin.test.ts`, `C3+C7`, any multi-segment case, run after
`waitForUpstreamSession('main')`: `session.sendSms({ dlr: true, message: 'g'.repeat(200), from,
to })` against Jasmin's `smpps`, with a `server()` bound as the fake upstream that only calls
`sms.sendResp()`/`sendDlr()` once its own `'sms'` event fires. No receipt arrives within Jasmin's
own 120s response timeout; `messages.log` on the peer shows the requeue.
**Severity.** Medium, narrow: needs a real relaying gateway whose own dispatch is serialized
per-outbound-link, which is exactly Jasmin's `smppc` connector shape - a directly-connected SMSC
(every other peer in this suite) never exhibits it, since it always has a response ready for every
segment as they arrive rather than being a relay itself. Worth a decision record either way (answer
each segment as it's held, not only once the group completes; or document that a `server()` sitting
behind a serializing relay needs its own segment-level ack), but not fixed here per the phase rules.
**Fixed** in [#83](https://github.com/larvit/larvitsmpp/pull/83): every segment is answered as it
arrives, with `<base>-<n>` off an id the group is opened with. All four multi-segment cases pass, in
200-360 ms each, malformed 0 and expert errors 0.
## Peer quirks
- **Jasmin's own `enquireLinkTimerSecs` (30, `[smpp-server]` default) is an idle timer, not a strict
period.** Our client's own default 20s keepalive counts as activity and resets it, so Jasmin's own
probe never gets a chance to fire on a link that's never quiet for 30s. `C1` disables our own
keepalive (`enquireLinkInterval: 0`, widening `idleTimeout` to compensate) to observe it.
- **Message ids are UUIDs, self-consistent end to end when the upstream hands out one id per PDU.**
Every id the real client's `submit_sm_resp` carried matched exactly what the fake upstream's
`sendResp()` assigned; the later receipt named the same id. Since the fake upstream here is this
library's own `server()`, a multi-segment message's ids came back in this library's own
`<base>-<n>` shape - an artifact of the test rig (our own server minting one base per group),
not evidence that Jasmin itself produces that convention; a real upstream SMSC would very likely
hand back unrelated ids per segment, as the research notes expected.
- **`smpps_throughput`, not the connector's `submit_throughput`, gates an ESME's own submission
rate.** The plan named `submit_throughput` "on the connector" for C12; that setting throttles the
connector's own *outbound* rate to its upstream. The knob that actually throttles submissions
*into* Jasmin's `smpps` from a bound ESME is a **user**-level quota
(`user -u <uid> mt_messaging_cred quota smpps_throughput <n>`), which does answer `ESME_RTHROTTLED`
reliably once exceeded.
- **`smppccm`'s `cid` must be 3-25 chars, `morouter`'s `smpps(<system_id>)` target is validated by
the same regex** (`[A-Za-z0-9_-]{3,25}`) - confirmed from `jasmin/protocols/cli/morouterm.py`.
- **`[dlr-thrower] dlr_pdu`** is a config-file setting (`deliver_sm` default, `data_sm` the other
option), not a per-connector or per-user key - the only way to test both is two Jasmin instances.
- **HTTP DLR callbacks carry fixed query args** (`id`, `level`, `message_status`, `connector`, plus
`id_smsc`/`sub`/`dlvrd`/`subdate`/`donedate`/`err`/`text` at `dlr-level` 2/3) appended by
`DLRThrower` itself to whatever bare URL `dlr-url` names - unlike Kannel's `%d`/`%F`
printf-style placeholders, nothing is written into the URL by the caller. `dlr-level=1` fires once,
immediately, with `message_status=ESME_ROK` (an SMSC-ack, not a terminal state); `dlr-level=3`
additionally fires once more, later, with the real terminal status (`DELIVRD` here).
- **Jasmin FINs the connection on a `deliver_sm_resp` carrying a `message_id`.** SMPP 3.4 4.6.2
makes that field unused and NULL, and Jasmin's own decoder sizes it at one octet; a 38-octet UUID
in it cost the link immediately after the response, taking the rest of the MO group with it. Found
by the fix for the multipart deadlock above, which is what first had this library answer an inbound
`deliver_sm` in this suite at all; the field now goes out empty.
- **jcli is a plain-text protocol dressed as Telnet** - it sends real `IAC`/option-negotiation bytes
and a couple of ANSI escapes in its banner, but never waits for or requires a reply to them; a raw
socket client that ignores negotiation entirely and just reads/writes lines works throughout.
## Open questions
- Whether Jasmin's own MT dispatch is serialized *per connector* specifically, or *globally* across
every connector on the instance - only one connector was configured, so the two are
indistinguishable here.
- Whether the deadlock above is specific to a message requesting a receipt (`registered_delivery`
set) or would also occur for a plain multi-segment send with no `dlr` - not isolated separately,
since every `C3+C7` case here requests one.
- Whether a real peer accepts a `data_sm_resp` carrying a `message_id`. SMPP 3.4 4.7.2 defines the
field, unlike `deliver_sm_resp`'s, and this library fills it when a `data_sm` carried a message -
but Jasmin only ever sends one as a receipt, which is answered with the field empty, so the filled
case has met no peer. Jasmin FINing over a `deliver_sm_resp` that carried one is the nearest
precedent there is.
- Whether Jasmin, given an *upstream* connector that itself defaults to SAR (rather than our
library's own UDH), would relay an MT message using SAR instead of preserving our UDH bytes - the
120s-timeout deadlock always intervened before a second segment could be observed on the wire in
either direction.
-102
View File
@@ -1,102 +0,0 @@
# 04 kannel
Date: 2026-09-05. Repo commit: `9c4939f` (working tree, phase 4 changes uncommitted on top).
Host Docker: 29.6.2. Images: `interop-kannel:1.4.5-12` (`debian:bookworm-20260824-slim` +
`kannel=1.4.5-12`, four config variants), `nicolaka/netshoot:v0.16` (capture sidecar and tshark),
`node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`).
## Setup
Four `bearerbox`+`smsbox` pairs, one Docker image, four config variants under
`interop-tests/peers/kannel/` (`main.conf`, `iv33.conf`, `maxpending1.conf`,
`notransceiver.conf`), all dialling the same `node:2775`. Only the `main` variant's link is
captured (each container only sees its own veth). Submits go in over `smsbox`'s `sendsms` HTTP API;
MO and DLR callbacks come out to a tiny HTTP receiver in `kannel.test.ts` (`/mo`, `/mo/iv33`,
`/mo/maxp1`, `/mo/notrx`, `/dlr`).
Two snags fixed while building the harness, both in the compose/config layer, not the peer:
- `smsbox`'s HTTP client to the `sms-service` `get-url` and to `dlr-url` occasionally lost the
connect race under this sandbox's networking ("Socket not connected", no retry by default).
Added `http-request-retry = 3` / `http-queue-delay = 1` to every `smsbox` group.
- 1.4.5-12 refuses one `group = smsc` block that sets both `port` and `receive-port` ("deprecated"
option combination) - `notransceiver.conf`'s separate TX/RX bind needs **two** `group = smsc`
blocks sharing one `smsc-id`, one with `port`, one with `receive-port`, not one block with both.
Two runs of `./interop-tests/run.py kannel`, back to back, both exit 0, both `tests 21, pass 21,
fail 0`:
```
run 1: frames 60, submit_sm 11/submit_sm_resp 8, deliver_sm 11/11, enquire_link 8/8, generic_nack 1
run 2: frames 66, submit_sm 11/submit_sm_resp 8, deliver_sm 12/12, enquire_link 10/10, generic_nack 1
```
malformed: 0, expert errors: 0, both runs. `submit_sm` outrunning `submit_sm_resp` and the varying
`enquire_link` count are the wait-ack-expiry scenario (below), not a defect - it deliberately holds
a response past Kannel's `wait-ack` window and lets Kannel reconnect.
## Scenarios (PLAN.md)
| Id | Result | Evidence |
| --- | --- | --- |
| S1 (binds 34, submits, receipts, MO) | pass | `kannel main variant - bind`; `S1 - MT from Kannel with delivery reports` (DELIVERED/UNDELIVERABLE/EXPIRED/ENROUTE); `MO to Kannel` |
| S1 (interface_version 0x33 sub-case, target 8) | pass | `iv33 variant - interface_version 0x33`: binds at 33, no TLVs on the receipt, still correlates |
| S6 (idle/keepalive) | pass | `S6 - wait-ack expiry and keepalive`: `enquire_link` every 5s keeps a 40s `idleTimeout` session alive; a deliberately-late `sendResp()` past `wait-ack` (5s) is recorded, not asserted against (Kannel's own choice, see below) |
| S11 (encodings, target 12) | pass | `€ [ ] ~ round trip through Kannel unpacked GSM7`; long GSM and UCS-2 (with 一 and an emoji) MT reassembly |
| target 11 (window) | pass | `maxp1 variant - max-pending-submits 1`: 20 sendsms calls, `max-pending-submits = 1`, all 20 arrive in order, none dropped or duplicated |
| target 8 (separate TX/RX) | pass | `notrx variant - separate TX and RX binds`: transmitter + receiver binds; submit_sm and MO/receipt only ever cross the intended bind |
## Wire facts
- **`dlr-mask=31`'s `%d` is not one code per SMPP state.** Kannel fires two HTTP callbacks per
settled message: `type=8` off the `submit_sm_resp` alone (before any receipt, `answer=ACK/`),
then a final one. DELIVERED is `type=1`, ENROUTE is `type=4`, UNDELIVERABLE is `type=2` - but
EXPIRED is `type=34` (`32|2`), its own bit, not folded into the generic failure code. A receiver
keying only on `1`/`2`/`4`/`8` will misfile an expired message as unhandled.
- **`%P` (destination) is smsbox's own `global-sender`, not the message's real destination.** With
no `my-number` set on the `smsc` group, an MO's `%P` reports the `smsbox` group's
`global-sender` value verbatim (here `46700000000`), not the `deliver_sm`'s `destination_addr`.
`%p` (source) additionally gets a `+` prepended for an international-TON address even though the
wire address carried none; `%P` gets no such `+`.
- **`%a` (MO text) is decoded for GSM but raw for UCS-2.** For a GSM-coded MO, `%a` is the decoded
human-readable string, safe to percent-decode as UTF-8. For UCS-2 (`coding=2`), `%a` is the
**raw big-endian UCS-2 bytes**, percent-escaped byte-for-byte (`一` → `%4E%00`, a lone surrogate
half → `%D8%3D` etc.) - not re-encoded as UTF-8 first. A receiver that runs
`URLSearchParams.get('text')` (or any UTF-8-aware percent-decoder) on a `coding=2` callback gets
mojibake, since those bytes are not valid UTF-8. The receiver must percent-decode to a raw byte
buffer and UCS-2-decode it itself, branching on `coding`. (This tripped the test harness itself
first - `kannel.test.ts`'s MO receiver now does exactly this via `moText()`/`percentDecodeBytes()`.)
- **A `submit_sm` in the wrong direction gets `generic_nack`, not a mismatched `*_resp`.** Calling
`session.sendSms()` on a link where Kannel is the ESME (submit_sm only flows ESME→SMSC) gets
answered with `generic_nack` carrying `ESME_RINVCMDID` - not a `submit_sm_resp`. Confirmed in the
capture (frame with `command_id 0x80000000` immediately answering a `0x00000004`).
`session.sendSms()`'s result surfaces this the same way it would a `submit_sm_resp` error
(`result.err.message` matches `ESME_RINVCMDID`), so nothing here needed different handling - but
a caller matching on response command id specifically would need to accept both.
- **`interface-version = "33"` negotiates cleanly.** No TLVs sent either way, receipts still carry
`id:`/`stat:` text and still correlate to the right `smsId`.
- **`wait-ack` expiry disconnects and reconnects, it does not retry in place.** Holding a
`submit_sm` response past Kannel's `wait-ack` (5s here) makes bearerbox log an I/O error and
redial; the late response lands on a session Kannel has already abandoned and is harmlessly
ignored. Kannel's own reaction, not a length this suite enforces.
- **`max-pending-submits = 1` is a strict one-at-a-time link.** A burst of sendsms calls all still
arrive, in the order smsbox forwarded them, but only as fast as this side answers each
`submit_sm` - the whole burst stalls behind an unanswered first message. Answering immediately
(not batching responses) is required to observe the burst complete at all.
## Defects in @larvit/smpp
None found against Kannel across two clean runs (21/21 both times). Two bugs surfaced during this
phase were both in the test harness, not `src/`, and are already fixed in `kannel.test.ts`:
1. The MO-text UTF-8-vs-raw-UCS2 decoding gap described above (`moText`/`percentDecodeBytes`).
2. The `max-pending-submits=1` burst test originally deferred every `sendResp()` to the end of the
test; under a window of 1, Kannel can't advance past the first unanswered `submit_sm`, so the
burst never arrived. Fixed by answering each `sms` as it lands.
## Open questions
- Whether `type=34` for EXPIRED is Kannel-version-specific, or whether REJECTD/DELETED have their
own similarly-unfolded bits - only EXPIRED was exercised here.
- Whether the raw-bytes-for-UCS2 `%a` behaviour also applies to `dlr-url`'s equivalent fields, or
is MO-specific - not exercised here (this suite's `dlr-url` never carries message text).
-210
View File
@@ -1,210 +0,0 @@
# 05 java clients
Date: 2026-09-06. Repo commit: `4194816` (working tree, phase 5 changes uncommitted on top). Host
Docker: 29.6.2. Images: `interop-jsmpp:3.0.3-a24db96` (jsmpp cloned at commit `a24db96`, built with
`maven:3.9.11-eclipse-temurin-21`, run on `eclipse-temurin:21.0.8_9-jre-jammy`),
`interop-cloudhopper:5.0.10-ae6485a` (fizzed/cloudhopper-smpp cloned at commit `ae6485a`, same
build/runtime image pair, plus a build-time self-signed cert for S10), `nicolaka/netshoot:v0.16`
(capture sidecar and tshark), `node:24.18.0-bookworm-slim` (test runner, from the root
`compose.yaml`).
## Setup
Each peer is a small Java driver (its own Maven project, `interop-tests/peers/<peer>/`) exposing an
HTTP command channel on 8080 - the node test file drives scenarios by calling it, the same shape as
`kannel.test.ts`'s `sendsms()` HTTP calls, and the same port doubles as the compose healthcheck
target. jsmpp's driver binds one or more named `SMPPSession`s and answers with the real client's own
exceptions and return values; Cloudhopper's binds one or more named `SmppSession`s the same way.
Long-message wire shapes (UDH 8/16-bit, `sar_*`, `message_payload`) are all built through jsmpp's own
typed `submitShortMessage(..., OptionalParameter...)` API, never a raw socket - jsmpp exposes exactly
the fields needed. Only three deliberately-malformed PDUs (an unknown command id, a truncated TLV
stream, a body shorter than `sm_length` declares) cannot be expressed through any typed SMPP client,
jsmpp included, so those go out over a second, plain `Socket` the driver opens alongside its jsmpp
session - noted here so "jsmpp accepts our answer" claims below are read as applying to the typed-API
scenarios only, where jsmpp's own reaction is what is being tested.
Build snags, both fixed in the Dockerfile, not in `src/`:
- Cloudhopper's parent pom (`fizzed-maven-parent:1.15`) hardcodes `<source>1.7</source>` /
`<target>1.7</target>` directly in its compiler-plugin config, which modern `javac` refuses
("Source option 7 is no longer supported") and which `-Dmaven.compiler.source` cannot override
since it is not read from a property. Fixed by `sed`-injecting a `<build>` block into
`ch-smpp`'s own pom (which declares none) with `source`/`target` `8`, the narrowest override that
changes nothing else.
- Cloudhopper's test sources reference `javax.annotation.PreDestroy` (JSR-250), removed from the JDK
this builds with. `-DskipTests` only skips running them; Maven's `install` lifecycle still
test-*compiles* them first. `-Dmaven.test.skip=true` skips compiling them too.
- Cloudhopper's `SslContextFactory` only takes the "no validation" branch when *neither* a keystore
nor a truststore is configured; a client with only a trust store falls through to
`loadKeyStore()` with a null path and fails ("SSL doesn't have a valid keystore"). Fixed by also
building a PKCS12 keystore from the same build-time self-signed cert and pointing
`SslConfiguration.setKeyStorePath()` at it - functionally unused (our server never requests a
client certificate) but required for Cloudhopper's own SSL setup to get past its own null check.
- The shared `cloudhopper-tls` volume Cloudhopper's entrypoint copies `server.key`/`server.crt`
into (so node's test file can load the same pair into `server({ tls })`) came out root-owned,
0600 - unreadable by `node`'s container, which runs as uid 1000. Fixed with a `chmod 644` in the
same entrypoint step.
- Cloudhopper's SSL client (Netty 3.9.6.Final, 2015-era) cannot complete a handshake against our
server's default TLS 1.3 - see Peer quirks. `server({ tls: { ..., maxVersion: 'TLSv1.2' } })` on
the S10 listener only; the plain listener the window scenarios use is unrestricted, and a Python
`ssl` client confirmed TLS 1.3 itself works before this was traced to the peer.
- One GSM7 encoding footgun in the jsmpp driver's own text fixtures, not in `@larvit/smpp`: the
driver's "gsm7" mode sends plain ASCII bytes for `short_message`, and GSM 03.38's default
alphabet maps ASCII `_` (0x5F) to `§`, not underscore - a message_payload fixture containing `_`
round-tripped as `§` until the character was dropped from the fixture text.
Two runs each of `./interop-tests/run.py jsmpp` and `./interop-tests/run.py cloudhopper`, all four
stable: jsmpp 12/12 both times, Cloudhopper 6/6 both times.
```
jsmpp: frames 37, submit_sm 8/8, query_sm 1/1, cancel_sm 1/1, replace_sm 1/1,
deliver_sm 2/2, enquire_link 2/2, generic_nack 1, malformed 2, expert errors 2
cloudhopper: frames 90-92 (varies slightly run to run - see Peer quirks), bind_transceiver 5/5,
submit_sm/submit_sm_resp present, unbind 4/4, malformed 0, expert errors 0
```
jsmpp's `malformed: 2` / `expert errors: 2` are not a stability problem: they are tshark
independently flagging the same two deliberately-malformed PDUs the "S3" scenarios send on purpose
(the truncated-TLV and short-body reproducers below) - both are genuinely malformed by the spec, so
an independent dissector agreeing is the expected outcome, not a surprise.
## Scenarios (PLAN.md)
| Id | Result | Evidence |
| --- | --- | --- |
| S2 UDH 8-bit (targets 2, 5) | pass | `jsmpp.test.ts` "UDH, 8-bit reference": one reassembled `sms`, segments answered `<base>-1`/`<base>-2` |
| S2 UDH 16-bit (target 5) | pass | `jsmpp.test.ts` "UDH, 16-bit reference": also reassembled - confirms both widths are read |
| S2 `message_payload` (target 2) | pass | `jsmpp.test.ts` "message_payload: one sms, the full text" |
| S2 `sar_*` (target 3) | defect confirmed, second peer; fixed in [#91](https://github.com/larvit/larvitsmpp/pull/91) | `jsmpp.test.ts` "sar_* (target 3)": one reassembled `sms`, segments answered `<base>-1`/`<base>-2` |
| S3 known-but-unhandled (targets 1, 6) | pass | `jsmpp.test.ts` "query_sm, cancel_sm, replace_sm": `ESME_RINVCMDID`, link survives, jsmpp raises `NegativeResponseException` and keeps going |
| S3 unknown command id (target 1) | pass | `jsmpp.test.ts` "an unknown command id gets generic_nack..." |
| S3 truncated TLV stream (target 1) | pass | `jsmpp.test.ts` "a deliver_sm with a truncated TLV stream..." |
| S3 short body (target 1) | pass | `jsmpp.test.ts` "a deliver_sm whose body is shorter than sm_length declares..." |
| Bind strictness (target 8) | pass, quirk noted | `jsmpp.test.ts` "bind version negotiation": 0x34 gets `sc_interface_version` back, 0x33 gets none; see Peer quirks for jsmpp's own negotiated-version report |
| Refusing status (jsmpp) | pass | `jsmpp.test.ts` "a refusing status is surfaced back to jsmpp" |
| S5 window 1/10/50 (target 11) | pass | `cloudhopper.test.ts` "S5 - window pressure...": all answered, no id answered twice, peak window never exceeds the configured size |
| S5 request expiry (target 11) | pass | `cloudhopper.test.ts` "the peer reports the expiry itself...": Cloudhopper's own window monitor reports it, our side does nothing unusual |
| S10 TLS | pass, quirk noted | `cloudhopper.test.ts` "handshake, bind, submit over TLS" |
| Refusing status (Cloudhopper) | pass | `cloudhopper.test.ts` "a refusing status is surfaced back to Cloudhopper" |
## Defects in @larvit/smpp
### `sar_*` segmentation confirmed unread, from a second independent peer (target 3)
What happened: jsmpp splits a message into two `sar_*`-tagged `submit_sm`s (no UDH, `esm_class`
carries no UDHI bit); each arrives at our server as its own, independent `sms` event carrying only
its own ~half of the text, with its own unrelated generated id - never merged into one message.
Confirms Jasmin's finding (`findings/03-jasmin.md`) from a second, independently-written client.
Spec: SMPP 3.4 5.3.2.16-5.3.2.18 defines `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum`
as an alternative to the UDH for carrying concatenation; nothing in the spec says a receiver may
ignore it.
Reproducer: `jsmpp.test.ts`, "sar_\* (target 3)" - two `submit_sm`s to the same
`source_addr`/`destination_addr`, `esm_class` 0x00, one `sar_msg_ref_num` (0x77) across both, `1/2`
then `2/2` in `sar_total_segments`/`sar_segment_seqnum`. Severity: as already scoped in PLAN.md
target 3 / phase 10 - a known, tracked limitation, not new.
**Fixed** in [#91](https://github.com/larvit/larvitsmpp/pull/91): `concatOf()` reads the
concatenation from the UDH, or from the `sar_*` TLVs where the PDU declares none, and each spelling
groups in a reference space of its own. The reproducer now asserts what a rerun shows - one `sms`
carrying the whole 200-char text, its two `submit_sm`s answered `<base>-1` and `<base>-2`, and
neither half ever reaching the application on its own. The rest of the suite is unchanged: 12/12,
frames 37, `submit_sm` 8/8, malformed 2 and expert errors 2 - the two deliberately malformed PDUs
the S3 scenarios send.
### A 4-octet truncated TLV tail is silently accepted rather than refused (target 1)
What happened: a `deliver_sm` whose mandatory fields are complete, followed by exactly one bare TLV
header (tag, 2-octet declared length) and *no* value octets at all, is answered `ESME_ROK` and its
mandatory-field text delivered as an ordinary `sms` - the TLV is silently dropped rather than the PDU
being refused with `ESME_RINVTLVSTREAM`, which is what the *same* codec path does correctly when a
few value octets (but still short of the declared length) follow the header instead of none.
Why: `pdu.ts`'s `pduToObj()` tries two parses of every PDU - "plain", and "padded" (some peers add a
trailing NUL after `short_message` for non-UDH text). Here "plain" parsing hits the TLV loop, reads a
length that overruns `command_length`, and correctly errors. But "padded" parsing shifts the TLV
region by one octet (treating the first of the four trailing octets as that padding NUL), leaving
only 3 octets - one short of what `parseTlvs()`'s loop needs even to read a tag+length pair
(`offset + 4 <= cmdLength` is false) - so the loop exits with no error and 3 octets unconsumed
(`aligned` false). `pduToObj()`'s fallback chain then reaches `if (!padded.err) return
{ pduObj: padded.pduObj }`, which accepts a misaligned parse whenever it produced no error, even
though 3 octets of the peer's PDU were never read. The fix is scoped to that fallback, not touched
here per the read-only rule.
Spec: SMPP 3.4 4.3 - a TLV field this codec cannot parse should be refused with
`ESME_RINVTLVSTREAM` (5.0's name; 3.4 spells it `ESME_RINVOPTPARSTREAM`), the same as the sibling
case with a few value octets present.
Reproducer (raw hex, sent after an ordinary `bind_transceiver`; independently reproduced with a
plain Python socket, no Java involved):
```
000000460000000500000000000000630000007261772d66726f6d0000007261772d746f00000000000000000000137472756e636174656420746c762070726f6265001d00c8
```
This is a `deliver_sm` (`source_addr` `raw-from`, `destination_addr` `raw-to`, body "truncated tlv
probe") followed by `00 1d 00 c8` - tag `0x001D`, declared length 200, zero value octets.
Our server answers `command_status 0x00000000` (`ESME_ROK`) and delivers the text as `sms`.
Appending 4 more arbitrary octets to the same tail (8 total, still declaring length 200) correctly
triggers `ESME_RINVTLVSTREAM` instead - `jsmpp.test.ts`'s "a deliver_sm with a truncated TLV stream"
test uses that 8-octet form deliberately, to test the *documented* refusal path rather than this
adjacent bug. Reproduced with jsmpp's own driver too:
`jsmpp.test.ts`, "a deliver_sm ending in a bare TLV header gets ESME_RINVTLVSTREAM, and reaches no
listener" - the same shape, over a `net.Socket` opened directly against `server()` (not through
jsmpp's typed API, which cannot build it at all). Severity: low - a narrow boundary condition (exactly 4
trailing octets, no value) rather than a general TLV-validation gap, but it is a hole in the fix
target 1 otherwise closed, silently dropping a TLV the peer meant to send instead of losing (and
counting) the one malformed PDU.
Fixed in [#87](https://github.com/larvit/larvitsmpp/pull/87): the optional parameters now have to end
on `command_length`, so this PDU is refused `ESME_RINVTLVSTREAM` like the sibling case. The hex above
is asserted octet for octet by `test/pdu.test.ts`, "refuses a bare TLV header the same way it refuses
a truncated value"; the same shape over a socket is `jsmpp.test.ts`, "a deliver_sm ending in a bare
TLV header gets ESME_RINVTLVSTREAM, and reaches no listener".
## Peer quirks
- **jsmpp's `session.getInterfaceVersion()` echoes what the driver declared, not what the earlier
research pass expected.** `research/esme-clients-and-validators.md` A2 quotes jsmpp's own source
(`scVersion != null ? IF_50.min(valueOf(scVersion)) : IF_34`) as defaulting to 3.4 whenever the
server's `bind_resp` omits `sc_interface_version`. Binding at 0x33 against our server (which
omits the TLV for a pre-3.4 peer) and reading `session.getInterfaceVersion()` back gives `0x33`,
not `0x34` - this getter does not visibly take that fallback branch here. Recorded as observed;
whether jsmpp's *internal* negotiated-version state (used, per its source, to decide whether to
attach optional parameters to requests it sends) differs from what this getter reports is not
established either way.
- **jsmpp accepts every one of our target-1/6 answers without closing the link.** `query_sm`,
`cancel_sm` and `replace_sm` each raise a catchable `NegativeResponseException` carrying
`ESME_RINVCMDID` (0x00000003); the session stays `BOUND_TRX` afterward (confirmed with a
follow-up `enquire_link`). A strict, actively-maintained Java client tolerates the exact answers
the interop plan's fixes promise.
- **Cloudhopper's SSL client (Netty 3.9.6.Final, last touched 2018) cannot complete a TLS 1.3
handshake.** `setUseSsl(true)` against our server's default listener fails immediately with
`org.jboss.netty.handler.ssl.NotSslRecordException: not an SSL/TLS record`, on the very first
record. A plain Python `ssl.SSLContext` client handshakes the same listener at TLS 1.3 without
issue, isolating the incompatibility to Cloudhopper's decade-old SSL stack rather than our
server. Capping the S10 listener at `maxVersion: 'TLSv1.2'` resolves it completely - bind,
submit and response all succeed. Not attempted: whether an older JRE for the *driver* (rather
than capping the server) would let Cloudhopper negotiate TLS 1.3 on its own terms.
- **Cloudhopper's window-monitor expiry and its own per-call timeout are different exceptions.**
Setting `requestExpiryTimeout` shorter than how long our (deliberately slow) handler holds a
message completes the blocking `session.submit(pdu, timeoutMs)` call early with
`RecoverablePduException`, distinct from the `SmppTimeoutException` a plain `timeoutMs` expiry
raises - worth telling apart in anything scripting around Cloudhopper's timeouts. Our side does
nothing unusual: the held message is answered on its own schedule, over the still-open socket,
once our slow handler gets to it; nothing server-side errors or is left in a half-finished state.
- **tshark's per-frame JSON export undercounts `submit_sm` frames under a tight concurrent
Cloudhopper burst on one TCP connection**, e.g. 90-92 total frames decoded across two otherwise
identical runs of the same test file (`dumpcap` itself reports zero drops: "Packets
received/dropped on interface 'any': 200/0"). Not chased further - `malformed`/`expert errors`
are unaffected (both 0 on every run), and the S5 assertions rely on the driver's own structured
per-request results, not the tshark histogram, for exactly this reason.
## Open questions
- Whether jsmpp's own negotiated-version fallback (the `IF_34` branch in its source) is reachable
through any observable other than `getInterfaceVersion()` - not established this phase.
- Whether the tshark frame undercount under a Cloudhopper burst is specific to `-T json` batch
export, or would also show up reading the same capture interactively - not investigated, time-
boxed.
-119
View File
@@ -1,119 +0,0 @@
# 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/<peer>/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 `<base>-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.
-164
View File
@@ -1,164 +0,0 @@
# 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
Re-examined 2026-09-20 to see whether it could be unblocked for the throughput comparison in
`benchmarks/`. Four things are now established, and one earlier suspicion is ruled out:
- **It is one write, not a split one.** A raw listener that accumulates every chunk rather than
reading the first receives `40 octets across 1 chunks`, byte-identical to the 2026-09-06 capture,
with `command_length` reading 2,752,512. So the two leading zero octets are absent from the socket
write itself; nothing about our framing or the capture is involved.
- **The compiled `pack/2` is correct**, checked in the built tree rather than the repository:
`Len = size(BodyBin) + 16` written as `<<Len:32, CmdId:32, 0:32, SeqNum:32>>`, returned as the
iolist `[Header, BodyBin]`. For this bind that is 16 + 26 = 42.
- **The remaining suspect is `smpp_session.erl:158`**, which writes with `erlang:port_command/2`
rather than `gen_tcp:send/2` — an undocumented fast path in oserl code that predates OTP 27.
- **That suspect is untested.** Two attempts to swap it were both invalidated by rebar3 dep caching:
editing a fetched dependency's source does not rebuild its beam, and the `_checkouts/` route
re-verifies every dependency, which needs network and git in the build container. Whoever picks
this up should patch before the first compile, or force the dep to rebuild, and confirm the beam
actually changed before believing a result.
Enough for an upstream report — a reproducer needing no SMSC, the exact octets, and a named
suspect — but not enough for a patch, since the one-line candidate has never actually run.
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
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.
The four scenarios share one network namespace, owned by `dumbclient-netns`, a container that
never exits - 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.
Runs 1 and 2 had `dumbclient-w2000` own the namespace. It exits once it has sent its 20,000, which
took every other client's network with it: the soak's responses stopped arriving, and its log filled
with `Expired TX packet` lines (`libsmpp`'s 7000ms `TX_MAX_TIMEOUT_MS`). Those runs read that as the
peer's own window bookkeeping stalling; run 3 (2026-09-26), with the namespace owned by a container
that outlives them all, reached 173,820 soak messages in 300s where run 2 reached 22,440. The soak
stays bounded by wall-clock (5 minutes), asserting every arrival answered, nothing duplicated, and
the memory shape.
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.
Three runs of `./interop-tests/run.py dumbclient`. Run 1 (the original 300,000-count soak) surfaced
the S6 harness bug above; run 2 fixed it; run 3, with the namespace owner above and the held-message
throttle in `src/`, is the one Scenarios reports. The capture figures below are run 2's.
`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
Run 3. `dumb-w500` ran to its full 20,000 in ~44s against a handler serialised to answer roughly
one message every 2ms (`SLOW_HANDLER_DELAY_MS`), `peakOutstanding` exactly 500. `dumb-w2000`, run
concurrently, held exactly 1000 and was throttled for the rest.
The soak (fast, immediate-response handler; window 100) reached 173,820 `submit_sm` over its fixed
300s, about 580/s, `peakOutstanding` 15. Sampled every 5s across the whole run (69 samples over
340s, all four scenarios combined, the harness's own per-message bookkeeping included): rss
first=165MiB, min=165MiB, max=298MiB, last=298MiB, heapUsed at the last sample 81MiB.
## 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) |
| S9 (bounded window) | pass | Run 3: `dumbclient.test.ts` "S9 - bounded window..." - window 500 20,000/20,000 answered in ~44s, `peakOutstanding` exactly 500; window 2000 5,639 answered and 14,361 throttled, in arrival order, no duplicate ids |
| Backpressure at the server | pass | Run 3: window 2000 holds exactly 1000 (`maxHeldMessages`, session-options.ts) and the rest is answered `ESME_RTHROTTLED`; smpp-dumb-client counts a throttled message as sent and never resends it; window 500 is never throttled |
| Long soak | pass | Run 3: `dumbclient.test.ts` "Long soak" - 173,820 arrived, 173,820 answered, 0 duplicates, 0 unanswered errors, `close()` drains with no error; rss 165MiB first, 298MiB max and last, heapUsed 81MiB last |
| 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.
## 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` treats `ESME_RTHROTTLED` as final** - a throttled message counts as sent
and is never resubmitted. Its `enquire_link` interval (10s once bound as an ESME) is 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.
@@ -1,207 +0,0 @@
# 09 operator receipt fixtures
Date: 2026-09-08. Repo commit at the start of the phase: `83176f5`. Host: Alpine 6.18.38-0-virt
kernel. Images: `node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`) — and
nothing else. No peer runs in this phase and no capture is taken.
## Setup
This phase has no peer to bring up, so `run.py` is not involved. The eight peers the suite can run
are all open source, and none of them writes the receipt bodies commercial operators document —
that whole class of behaviour is what
[research/operator-quirks.md](../research/operator-quirks.md) topics 4 and 5 collected, one source
URL per claim, and what this phase turns into fixtures in `test/`.
Everything here runs under the ordinary suite:
```bash
docker compose run --rm node npm test
```
New file: `test/operator-receipts.test.ts` — a table of receipt bodies as each operator's own
documentation spells them, checked through `dlrFromPdu()`, plus four scenarios driven over a live
link against a dummy SMSC that answers each `submit_sm` with the message id the fixture names. Each
fixture carries the URL it was read from as its assertion message, so a failure names the page that
settles it. That dummy SMSC is `test/dummy-smsc.ts`, extracted from the copy `messaging-mode.test.ts`
already carried rather than written a second time. `test/session-extras.test.ts` gained C8's 16-bit
UDH and `test/session.test.ts` the `DlrMerger` fact the Telesign scenario turned up.
## What the research settles, and what it does not
Several things in topic 5 could not be taken at face value, and are recorded rather than guessed at:
- **Telesign's `err` width contradicts itself.** The page calls it "a 3-octet hex code" and then
gives 8-hex-digit examples (`0x000004A6`), which is four octets
(https://developer.telesign.com/enterprise/docs/smpp-protocol). smpp.org fixes the receipt field
at 3 octets. The fixture takes the 3-octet width and the hex notation (`err:4A6`) and asserts the
value reaches `dlr.errorCode` verbatim — this library never parses `err:`, so either reading
arrives intact at the application, which is the only claim the sourced material supports.
- **tyntec does not document the `stat:` its buffered receipt carries**, only that a buffered one
precedes the final one. The fixture uses `stat:ENROUTE` under `esm_class` 0x04 — Appendix B's own
spelling for a message still on its way, and the shape Infobip documents explicitly — rather than
inventing a vendor token.
- **Telesign's `message_parts_count` TLV has no published tag id** in either sourced page, so no
fixture names one. The behaviour it accompanies (only the first segment answered with a
`message_id`) is covered without it.
- **Telesign's `message_state` 9 is not a receipt body shape**, so it is not in the operator table.
Appendix B has no seven-character code for `SKIPPED`, so a fixture pairing the two would have had
Telesign's body and TLV contradict each other where its own page says the status is stated
redundantly in both. The TLV rule is asserted where it belongs instead — `dlr.test.ts` "names a
state only the TLV can spell" — and the Telesign fixture states one status in both fields, as
documented. The research file of 2026-09-05 is the source for 9 = `SKIPPED`; the TLV page no
longer shows that table.
- **Vonage's `stat:` set could not be re-fetched** during review (the support article answers 403).
Kaleyra and Route Mobile both verify independently and both define `FAILED` as a terminal delivery
failure, which is what the library change rests on; Vonage's own developer page documents a
lower-case status set for its HTTP callbacks, which is a different surface from the seven-character
`stat:` field. The fixture keeps the research's attribution, and `src/dlr.ts` cites the two that
verify.
- **Clickatell's cited page no longer resolves** — `archive.clickatell.com/developers/api-docs/pdu-details/`
now redirects to `docs.clickatell.com`. The body shape is quoted verbatim in the research file of
2026-09-05, which is what the fixture was built from.
Two further items in topic 5 are not receipt-body shapes at all and are out of this phase:
Syniverse's and Route Mobile's numeric status tables are vendor fields of their own rather than the
seven characters `stat:` holds, and LINK Mobility's `registered_delivery=0x21` is a submit field.
## Scenarios (PLAN.md)
| Id | Result | Evidence |
| --- | --- | --- |
| C16 LINK Mobility: `sub:000`, `dlvrd:000`, empty `text:`, finals only | pass | `operator-receipts.test.ts` "LINK Mobility, whose sub and dlvrd are always 000…" — `receipt.sub`/`receipt.dlvrd` read 0 and nothing is derived from them, `receipt.text` is `''`; "finds none of LINK Mobility's among the transient ones" |
| C16 Vonage: `stat:FAILED` outside Appendix B, eight-value set, `err:` off `DELIVRD`/`ACCEPTD` only | fail, then fixed | "Vonage, whose stat:FAILED is six characters and outside Appendix B" and "names a state of its own for every one of them" — both failed against `83176f5`; see Defects |
| C16 Vonage: one receipt per segment | pass | "reports every segment and merges nothing" — three `dlr` events under the SMSC's own three unrelated ids, no `messageDlr` |
| C16 tyntec: a buffered receipt then a final one for one id | pass | "hands both receipts to the application rather than taking the second for a duplicate" — two `dlr` events, `intermediate` `[true, false]`, one `smsId` |
| C16 Infobip: `stat:ENROUTE` marked `esm_class` 0x04 | pass | "Infobip, reporting ENROUTE in an ordinary receipt that carries no text field at all" — `intermediate` true off the state where the marker says final, and `receipt.text` stays `undefined` |
| C16 Clickatell: the exact documented body | pass | "Clickatell, whose dates carry seconds" — every field of the documented order, 12-octet dates |
| C16 Telesign: hex `err:`, one id per concatenated send | pass | "Telesign, whose err is hexadecimal and whose status is stated in the body and the TLVs alike"; "hands the err field over as it arrived, whichever width the operator writes"; "hands back what landed where only the first segment is answered with one" |
| C16 CM.com: a four-digit year, an eight-character `stat:`, no `sub:` or `dlvrd:` | fail, then fixed | "CM.com, which writes a four-digit year, an eight-character stat, and the status twice" — absent fields stay `undefined` and the `message_state` TLV agrees with `stat:`, but both the documented `yyyyMMddHHmmss` date and the documented `stat:DELIVERD` failed against `83176f5`; see Defects |
| C16 a receipt with no `id:` at all | pass | "settles a status against no message where a marked receipt names no id" — marked, `smsId` is undefined and the status still settles; unmarked, the same body arrives as an `sms`. "takes the id from the TLV where the body names none" covers the third case |
| C16 fields in another order | pass | "reads the same fields whatever order they arrive in"; "reads the rest of the line as the text where a peer does not write text last" |
| C16 hex `message_id` against a decimal `id:` | pass | "correlates the receipt against the send once both notations are named" and "leaves the two incomparable where neither notation is named" |
| C16 a zero-padded id | pass | "strips the padding an operator writes the same number with" |
| C16 dates with and without seconds | pass | The LINK Mobility and Infobip fixtures carry 10-octet dates, Clickatell and Telesign 12-octet ones and CM.com a 14-octet one; every one asserts the `Date` it resolves to, and `dlr.test.ts` "reads a receipt date whichever of the three widths the peer writes it in" pins all three against each other |
| C8 UDH 16-bit | pass | `session-extras.test.ts` "names the spelling a segment was numbered by, alongside the reference" reads GSM 03.40 element 0x08, and "assembles a message numbered by a 16-bit UDH reference" carries two of them through the `Reassembler` into one whole text — the width was previously exercised only by the jsmpp peer run ([05-java-clients.md](05-java-clients.md)) |
| C9 MO or receipt on `data_sm` | pass, already covered | `dlr.test.ts` "reads a receipt the peer carried in message_payload, on deliver_sm and on data_sm"; `session-extras.test.ts` "reads a data_sm as the command its direction makes it" |
| C10 unknown command id, malformed and vendor TLVs | pass, already covered | `test/raw-pdus.ts` and the `session.test.ts` refusal suites |
| C15 `interfaceVersion` 0x50, a peer answering 3.3 or nothing | pass, already covered | `session.test.ts` bind-version suites around `sc_interface_version` |
## Defects in @larvit/smpp
### `stat:FAILED` read as `UNKNOWN`
**What happened.** A receipt body carrying `stat:FAILED` reached the application as
`statusMsg: 'UNKNOWN'`, `statusId: 7` — indistinguishable from a receipt that really says
`stat:UNKNOWN`.
**What the operators' docs say.** Vonage lists `FAILED` among the eight `stat` values it writes
(https://api.support.vonage.com/hc/en-us/articles/204015663), Kaleyra among its four
(https://messaging.kaleyra.com/support/solutions/articles/3000091798-delivery-reports), and Route
Mobile among its five (https://routemobile.com/pdf_files/developer/api/routemobilesmpp.pdf). In all
three it is a terminal delivery failure. SMPP 3.4 Appendix B does not define it, and it is six
characters where the field is seven.
**Reproducer.** `operator-receipts.test.ts`, the Vonage fixture and "names a state of its own for
every one of them" — the second walks every code all seven researched operators publish and fails
on any that reads as `UNKNOWN` without saying `UNKNOWN`.
**Severity.** Two ways it gives a wrong answer, both goal 2: an application cannot tell an
operator's "it failed" from its "I do not know", and `DlrMerger` ranks `UNKNOWN` (5) below `EXPIRED`
(6), so a multipart send with one failed segment and one expired one reported as expired.
**Fixed** in this phase: one entry added to `receiptStates` in `src/dlr.ts`. `receiptCodes` is
untouched, so this library still only ever writes `UNDELIV`. Decision recorded in the root
`AGENTS.md` under "The wire".
### CM.com's own `stat:` spelling read as `UNKNOWN`
**What happened.** A receipt spelled the way CM.com's code table prints it reached the application as
`statusMsg: 'UNKNOWN'`. Unmarked, it arrived as an inbound `sms` rather than as a report at all.
**What the operator's docs say.** The "Message state values" table at
https://developers.cm.com/messaging/docs/smpp gives the code column as `DELIVERD` — eight
characters — beside `EXPIRED`, `DELETED`, `UNDELIV`, `ACCEPTD`, `UNKNOWN` and `REJECTD`, which are
all correct Appendix B codes. The page prints `DELIVERD` four times and `DELIVRD` not once, verified
by fetching it.
**Reproducer.** `operator-receipts.test.ts` "names a state of its own for every one of them", whose
CM.com row now carries the published spelling, and the CM.com fixture.
**Severity.** The same class as the `stat:FAILED` defect above, on the most common status there is: an
application could not tell a delivered message from one whose state the library could not read.
**Fixed** in this phase: one entry in `receiptStates`. Whether CM.com's table is a typo or its wire
spelling, reading it costs nothing — no other code could be meant, and `receiptCodes` still writes
only `DELIVRD`. This supersedes the research file's CM.com line, which records the code as `DELIVRD`
and asks for an assertion that every `stat:` is exactly seven characters: written today, that
assertion fails against the page it cites.
### A receipt date carrying its century dropped
**What happened.** `dlr.doneDate` and every other parsed date came back `undefined` for a receipt
whose dates are 14 digits, while `dlr.receipt.doneDate` still carried the raw string — so the loss
was silent.
**What the operator's docs say.** CM.com gives its receipt body template as
`id:… submit date:yyyyMMddHHmmss done date:yyyyMMddHHmmss stat:SSSSSSS err:EEE`, with "Formatted:
yyyyMMddHHmmss" spelled out (https://developers.cm.com/messaging/docs/smpp). `receiptDate()` read 10
and 12 digits only — smpp.org's `YYMMDDhhmm` and the same with seconds.
**Reproducer.** `dlr.test.ts` "reads a receipt date whichever of the three widths the peer writes it
in", and the CM.com row of the operator table.
**Severity.** Goal 3: a date the receipt states plainly is one the library can determine, and
dropping it leaves the application to re-parse `dlr.receipt.doneDate` itself. The three widths are
10, 12 and 14, so none can be read as another and nothing is guessed.
**Fixed** in this phase: `receiptDate()` in `src/dlr.ts` takes a four-digit year as the year, where a
two-digit one still means this century, and the rolled-over check now covers the year as well —
`Date.UTC` reads 26 as 1926.
## Peer quirks
Not peer behaviour this time — operator behaviour, from documentation rather than from a run. What
the fixtures pin that a reader would not otherwise expect:
- **`sub:` and `dlvrd:` say nothing.** LINK Mobility hardcodes both to `000` on every receipt,
delivered ones included, and CM.com omits them entirely. Nothing in this library derives anything
from either, which is what makes both readable.
- **`text:` can only end where the line does**, because it is the one field allowed to hold spaces.
Every researched operator writes it last, and one that did not would have the rest of its line
read as the text. The other seven fields are order-independent.
- **A receipt's `esm_class` and its `stat:` can disagree about finality.** Infobip writes
`stat:ENROUTE` under 0x04, the marker for a final receipt; the state wins, which is why the
library tests both.
- **An operator that hands out an unrelated id per segment gets no `messageDlr`.** Vonage sends one
receipt per segment under ids that carry no `<base>-<n>` numbering, so nothing merges them. An
application wanting one report per message compares each `dlr.smsId` against the `smsIds` array
`sendSms()` returned and merges them itself.
- **Telesign answers only the first segment of a concatenated submit with a `message_id`.**
`sendSms()` returns `['<id>', undefined, undefined]` — one entry per segment, positional with
`pduObjs`, `undefined` where the SMSC named nothing; it returned `''` there until #101.
`dlr.smsId` is never empty, so an unnamed entry matches no receipt, and no merge is armed.
## Open questions
- **Whether LINK Mobility really writes a space after the colon.** Its guide prints the extended
format as `id: xxx sub:000 dlvrd:000 submit date: yyMMddHHmm ... stat: <status> err: <error code>
text:` — spaced before every placeholder and unspaced before both literal `000`s, which reads as a
typographic convention for placeholders rather than as the wire shape. The fixture takes the
unspaced form every other operator documents. It matters because the parser reads a field as
ending at the first space, so a genuinely spaced receipt yields every field empty, and an unmarked
one would arrive as an inbound message. Tolerating a space cannot simply be added: `sub: stat:UNDELIV`
would then read `stat:UNDELIV` as the value of `sub`, which is the same ambiguity the other way
round. A single receipt off a real LINK link settles it; until then the shape is not guessed at.
- Whether Telesign's `err:` is really three hex characters or eight. Both readings reach the
application unchanged — "hands the err field over as it arrived, whichever width the operator
writes" pins that — so nothing in this library turns on it, but a real Telesign link would settle
it in one receipt.
- What `stat:` tyntec's buffered receipt actually carries. The library reads any of `ENROUTE`,
`SCHEDULED` or `esm_class` 0x20 as non-final, so all three plausible answers behave correctly; a
fourth, vendor-invented token would read as `UNKNOWN` and final.
- Whether any operator writes a `stat:` outside Appendix B beyond the two this phase found,
`FAILED` and CM.com's `DELIVERD`. The documented-codes table in `operator-receipts.test.ts` is
the place a new one goes, and it fails loudly for anything nothing names.
- Whether `smsIds` carrying an empty entry for a segment the SMSC took but named no id for is the
right shape for a caller. Settled in #101: the entry is `undefined`, and the decision with its
rejected alternatives is recorded in `AGENTS.md`.
-709
View File
@@ -1,709 +0,0 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import test, { after, describe } from 'node:test';
import type { Dlr } from '../src/protocol/dlr.ts';
import type { EncodingName } from '../src/codec/encodings.ts';
import type { PduObject } from '../src/codec/pdu.ts';
import type { Session } from '../src/session/session.ts';
import type { Sms } from '../src/session/sms.ts';
import { ConcatReference } from '../src/protocol/udh.ts';
import { client } from '../src/client/client.ts';
import { closeAfter } from '../test/teardown.ts';
import { paramText } from '../src/codec/types.ts';
import { server } from '../src/server/server.ts';
import { encodeMessage, splitMessage } from '../src/message.ts';
import { submitSmParams } from '../src/messages/submit.ts';
const PEER_HOST = process.env.PEER_HOST ?? 'jasmin';
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
const DATASM_HOST = process.env.DATASM_HOST ?? 'jasmin-datasm';
const HTTP_API_HOST = process.env.HTTP_API_HOST ?? 'jasmin';
const HTTP_API_PORT = Number(process.env.HTTP_API_PORT ?? '1401');
const UPSTREAM_PORT = Number(process.env.UPSTREAM_PORT ?? '2777');
const CALLBACK_PORT = Number(process.env.CALLBACK_PORT ?? '8080');
const USERNAME = 'esme1';
const PASSWORD = 'esme1pw';
const THROTTLED_USERNAME = 'esme2';
const THROTTLED_PASSWORD = 'esme2pw';
const UPSTREAM_USERNAME = 'upstreamesme';
const UPSTREAM_DATASM_USERNAME = 'upstreamdsesme';
const FROM = '46701113311';
const TO = '46709771337';
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
/** Polls until `get()` stops returning undefined, or the budget runs out. */
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;
}
async function bind(username: string, password: string, options: Parameters<typeof client>[0] = {}): ReturnType<typeof client> {
return client({ host: PEER_HOST, password, port: PEER_PORT, username, ...options });
}
// --- Shared infra: the fake upstream real-world SMSC that Jasmin's own smppc connector(s) bind
// out to (host `node`, port UPSTREAM_PORT - Jasmin resolves `node` via --use-aliases), and the HTTP
// listener that receives Jasmin's DLR-thrower webhook (S7). Both are wired once for the whole file,
// mirroring kannel.test.ts's shared-infra shape - every Jasmin variant dials in from container
// start, independent of when a given test runs. ---
type UpstreamVariant = 'datasm' | 'main';
const upstreamSessions = new Map<UpstreamVariant, Session>();
const upstreamSms: { sms: Sms; variant: UpstreamVariant }[] = [];
function variantFromSystemId(systemId: string): UpstreamVariant | undefined {
if (systemId === UPSTREAM_USERNAME) return 'main';
if (systemId === UPSTREAM_DATASM_USERNAME) return 'datasm';
return undefined;
}
const { err: upstreamErr, server: upstream } = await server({
authenticate: ({ password, systemId }) => {
// <=8 chars: Jasmin's own bind-PDU encoder enforces SMPP's 8-char password maximum strictly
// (see bootstrap.py) - a longer one made every single connector bind attempt throw.
if (password !== 'upstrmpw') return false;
const variant = variantFromSystemId(systemId);
return variant ? { userData: { variant } } : false;
},
// Jasmin's connector sends enquire_link every elink_interval (30s); this host's own contention
// (rabbitmq/redis under load) sometimes delays it past our server's 40s default idleTimeout,
// dropping the one long-lived connector session. A dropped connection strands whatever submit_sm
// was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past
// any per-test wait budget here - so this is generous specifically to never be the trigger.
idleTimeout: 300_000,
port: UPSTREAM_PORT,
});
assert.equal(upstreamErr, undefined);
assert.ok(upstream);
const upstreamServer = upstream;
upstreamServer.on('session', session => {
// `session` fires on raw connect, before authenticate() has run - session.userData is not set
// yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off
// userData; userData is only read later, from 'sms', where authenticate() has long since run.
session.on('incomingPduObj', pduObj => {
if (!pduObj.cmdName.startsWith('bind_')) return;
const variant = variantFromSystemId(paramText(pduObj.params.system_id));
if (variant) upstreamSessions.set(variant, session);
});
session.on('sms', sms => {
const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant;
if (variant) upstreamSms.push({ sms, variant });
void (async () => {
await sms.sendResp();
if (sms.dlr) {
await delay(150);
await sms.sendDlr('DELIVERED');
}
})();
});
});
async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise<Session> {
const found = await waitFor(() => upstreamSessions.get(variant), budget);
assert.ok(found, `Jasmin's ${variant} connector never bound to our fake upstream within ${String(budget)}ms`);
return found;
}
type DlrCallback = { id: string; messageStatus: string };
const dlrCallbacks: DlrCallback[] = [];
const httpServer = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://node');
if (url.pathname === '/dlr') {
dlrCallbacks.push({
id: url.searchParams.get('id') ?? '',
messageStatus: url.searchParams.get('message_status') ?? '',
});
res.writeHead(200);
res.end();
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>(resolve => { httpServer.listen(CALLBACK_PORT, resolve); });
after(async () => {
await upstreamServer.close();
await new Promise<void>(resolve => { httpServer.close(() => { resolve(); }); });
});
/** dlr-level 1 (SMSC-ack) fires once, immediately, with message_status ESME_ROK - not a terminal
* state - so a caller after the final DELIVRD needs dlr-level 3 (both) and its own status filter. */
async function waitForDlrCallback(id: string, status: string, budget = 15_000): Promise<DlrCallback> {
const found = await waitFor(() => dlrCallbacks.find(callback => callback.id === id && callback.messageStatus === status), budget);
assert.ok(found, `no dlr callback for id=${id} status=${status} arrived (seen: ${JSON.stringify(dlrCallbacks)})`);
return found;
}
/** Jasmin's HTTP send API (`/send`): `to` must be digits only, `content` is the message body. */
async function httpSend(params: Record<string, string>): Promise<{ body: string; status: number }> {
const url = new URL(`http://${HTTP_API_HOST}:${String(HTTP_API_PORT)}/send`);
url.search = new URLSearchParams({ password: PASSWORD, username: USERNAME, ...params }).toString();
const response = await fetch(url, { method: 'POST' });
const body = await response.text();
return { body, status: response.status };
}
const sarReference = new ConcatReference();
/** Splits into SAR segments: `splitMessage()`'s own encoding/chunking, its UDH stripped back off. */
function sarPayloads(message: string, encoding?: EncodingName): Buffer[] {
const reference = sarReference.next();
const segments = splitMessage(message, encoding === undefined ? { reference } : { encoding, reference });
return segments.length > 1 ? segments.map(segment => segment.subarray(6)) : segments;
}
/** Pushes a long MO into Jasmin over `session` (Jasmin's smppc connector bound to us) as
* `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` segments - Jasmin's own documented
* default MT segmentation (target 3). */
async function sendSarMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
const payloads = sarPayloads(opts.message);
const refNum = sarReference.next();
for (const [index, payload] of payloads.entries()) {
const sent = await session.send({
cmdName: 'deliver_sm',
params: {
destination_addr: opts.to,
short_message: payload,
source_addr: opts.from,
},
tlvs: {
sar_msg_ref_num: { tagValue: refNum },
sar_segment_seqnum: { tagValue: index + 1 },
sar_total_segments: { tagValue: payloads.length },
},
});
assert.equal(sent.err, undefined);
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
}
}
/** As `sendSarMo`, but UDH concatenation (esm_class 0x40) - the same shape `sendSms()` emits, and
* Jasmin's documented "older system compatibility" alternative. */
async function sendUdhMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
const reference = sarReference.next();
const segments = splitMessage(opts.message, { reference });
const multipart = segments.length > 1;
for (const segment of segments) {
const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding: 'ASCII', multipart });
const sent = await session.send({ cmdName: 'deliver_sm', params });
assert.equal(sent.err, undefined);
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
}
}
/** A `deliver_sm` with `sm_length` 0 and the body in `message_payload` (target 2). */
async function sendMessagePayloadMo(session: Session, opts: { from: string; message: string; to: string }): ReturnType<Session['send']> {
return session.send({
cmdName: 'deliver_sm',
params: {
destination_addr: opts.to,
short_message: Buffer.alloc(0),
source_addr: opts.from,
},
tlvs: {
// The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM.
message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer },
},
});
}
describe('C1 - bind, enquire_link, unbind', () => {
test('binds transceiver, sees Jasmin\'s own enquire_link, unbinds clean', async () => {
// Jasmin's own enquireLinkTimerSecs (30) is an idle timer, not a strict period: our client's
// own default 20s keepalive counts as activity and resets it, so Jasmin's probe never has a
// chance to fire on its own. Disabling ours (and widening idleTimeout, which defaults off
// enquireLinkInterval and would otherwise become 0) leaves the link quiet long enough to see it.
const { err, session } = await bind(USERNAME, PASSWORD, { enquireLinkInterval: 0, idleTimeout: 60_000 });
assert.equal(err, undefined);
assert.ok(session);
const incoming: PduObject[] = [];
const closes: true[] = [];
const sessionErrors: Error[] = [];
session.on('incomingPduObj', pduObj => { incoming.push(pduObj); });
session.on('close', () => { closes.push(true); });
session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
// enquireLinkTimerSecs is 30 in Jasmin's default [smpp-server] config.
const theirs = await waitFor(() => incoming.find(pduObj => pduObj.cmdName === 'enquire_link'), 35_000);
assert.ok(theirs, 'expected Jasmin to send its own enquire_link within 35s');
const ours = await session.send({ cmdName: 'enquire_link' });
assert.equal(ours.err, undefined);
assert.ok(ours.pduObj);
assert.equal(ours.pduObj.cmdStatus, 'ESME_ROK');
const unbound = await session.unbind();
assert.equal(unbound.err, undefined);
assert.ok(await waitFor(() => (closes.length > 0 ? true : undefined), 5000), 'expected a clean close after unbind');
assert.deepEqual(sessionErrors, []);
});
test('binds transmitter', async t => {
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'transmitter' });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
assert.equal(session.boundAs, 'transmitter');
});
test('binds receiver', async t => {
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
assert.equal(session.boundAs, 'receiver');
});
});
describe('C13 - maxOutstanding 1 with 10 parallel sends', () => {
test('every send is answered, none lost, order preserved at the fake upstream', async t => {
await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { maxOutstanding: 1 });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const before = upstreamSms.length;
const texts = Array.from({ length: 10 }, (_, i) => `c13-order-${String(i).padStart(2, '0')}`);
const results = await Promise.all(texts.map(async message => session.sendSms({ from: FROM, message, to: TO })));
const ids: string[] = [];
for (const result of results) {
assert.equal(result.err, undefined);
assert.equal(result.smsIds.length, 1);
const [smsId] = result.smsIds;
assert.ok(smsId);
ids.push(smsId);
}
assert.equal(new Set(ids).size, ids.length, 'expected 10 distinct message ids, none lost or duplicated');
const arrivedOrder = await waitFor(() => {
const seen = upstreamSms.slice(before).filter(e => e.variant === 'main').map(e => e.sms.message);
return texts.every(text => seen.includes(text)) ? seen : undefined;
}, 60_000);
assert.ok(arrivedOrder, 'not all 10 messages reached the fake upstream');
const ordered = arrivedOrder.filter(m => texts.includes(m));
assert.deepEqual(ordered, texts, 'maxOutstanding:1 should serialise sends end to end, in order');
});
});
describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callback)', () => {
test('a message pushed through /send arrives as submit_sm at our server; our receipt fires Jasmin\'s DLR webhook', async () => {
const before = upstreamSms.length;
// No printf-style placeholders (unlike Kannel's %d/%F): DLRThrower appends its own fixed
// query args - id, level, message_status, connector - to this bare URL (confirmed from
// jasmin/routing/throwers.py). dlr-method=get puts them in the query string; POST (Jasmin's
// own default) would need a form-body reader instead.
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr`;
const sent = await httpSend({
content: 's7 http send test',
dlr: 'yes',
// Level 3 (both): level 1 alone fires once, immediately, with message_status ESME_ROK -
// the SMSC-ack, not a terminal state - so proving Jasmin parses our own receipt needs the
// terminal-level callback too, which only follows the sendDlr('DELIVERED') below.
'dlr-level': '3',
'dlr-method': 'get',
'dlr-url': dlrUrl,
from: FROM,
to: TO,
});
assert.match(sent.body, /Success/i);
const arrived = await waitFor(() => upstreamSms.slice(before).find(e => e.variant === 'main' && e.sms.message === 's7 http send test'), 60_000);
assert.ok(arrived, 'expected the HTTP-submitted message to arrive as submit_sm at our server()');
// Our server() already answered (sendResp) and sent the DLR (sendDlr('DELIVERED')) from the
// shared session handler above - Jasmin's own DLR pipeline should throw the HTTP callback.
const msgidMatch = /Success "([^"]+)"/i.exec(sent.body);
const msgid = msgidMatch?.[1];
assert.ok(msgid, `expected /send's response to carry a message id: ${sent.body}`);
const ack = await waitForDlrCallback(msgid, 'ESME_ROK', 15_000);
assert.equal(ack.id, msgid);
const final = await waitForDlrCallback(msgid, 'DELIVRD', 15_000);
assert.equal(final.id, msgid);
});
test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = `s7-long-${'p'.repeat(300)}`;
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
await session.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? sms.length > 0, 'expected the long message to arrive whole or as recorded fragments');
});
test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = `一😀${'q'.repeat(60)}`;
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
await session.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? sms.length > 0, 'expected the UCS-2 message to arrive whole or as recorded fragments');
});
});
describe('C3+C7 - long MT through the fake upstream, receipts and id consistency', () => {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(-\d+)?$/i;
const cases: { encoding?: 'UCS2'; expectedSegments: number; label: string; message: string }[] = [
{ expectedSegments: 1, label: 'single-segment GSM with extension chars', message: '€[]~single segment' },
{ expectedSegments: 2, label: '2-segment GSM with extension chars', message: `€[]~${'g'.repeat(200)}` },
{ expectedSegments: 3, label: '3-segment GSM with extension chars', message: `€[]~${'g'.repeat(400)}` },
{ expectedSegments: 10, label: '10-segment GSM with extension chars', message: `€[]~${'g'.repeat(1450)}` },
{ encoding: 'UCS2', expectedSegments: 2, label: '2-segment UCS2 with 一 and an emoji', message: `一😀${'x'.repeat(70)}` },
];
for (const testCase of cases) {
test(testCase.label, async t => {
await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD);
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const dlrs: { dlr: Dlr; pduObj: PduObject }[] = [];
const messageDlrs: unknown[] = [];
session.on('dlr', (dlr, pduObj) => { dlrs.push({ dlr, pduObj }); });
session.on('messageDlr', merged => { messageDlrs.push(merged); });
const sent = await session.sendSms({
dlr: true,
from: FROM,
message: testCase.message,
to: TO,
...(testCase.encoding ? { encoding: testCase.encoding } : {}),
});
assert.equal(sent.err, undefined);
assert.equal(sent.smsIds.length, testCase.expectedSegments);
for (const id of sent.smsIds) {
assert.ok(id, 'expected Jasmin to name a message id for every segment');
assert.match(id, uuidPattern, 'expected a UUID-shaped message id from Jasmin\'s submit_sm_resp');
// The full round trip - submit_sm to Jasmin's smpps, mtrouter, AMQP, the connector bind,
// our fake upstream's ack+DLR, AMQP again, DLRLookup, deliver_sm back - is slower than a
// single-segment send's, and visibly so under load; a generous budget beats a flaky one.
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === id), 25_000);
assert.ok(received, `no receipt for id ${id}`);
assert.equal(received.dlr.statusMsg, 'DELIVERED');
}
// Jasmin relays each segment as its own independent submit_sm to the connector (no MT-side
// UDH reassembly observed here - see findings), so the ids Jasmin hands back are whatever
// the fake upstream's own server() assigned per segment of ITS OWN reassembled view. That
// is this library's own <base>-<n> convention on both ends of this harness, which is why
// messageDlr can fire here - not evidence of Jasmin producing that convention itself; see
// findings/03-jasmin.md for what a real, independent upstream SMSC would hand back instead.
void messageDlrs;
});
}
});
describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => {
test('SAR-segmented deliver_sm from the fake upstream', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = `sar-mo-${'m'.repeat(300)}`;
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
const fragments = sms.filter(s => s.message !== text && text.includes(s.message) && s.message !== '');
await session.close({ signal: AbortSignal.abort() });
assert.ok(whole, 'expected the SAR segments to reassemble into one whole sms');
assert.deepEqual(fragments.map(s => s.message), [], 'no segment reaches the application on its own');
});
test('UDH-segmented deliver_sm from the fake upstream', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = `udh-mo-${'n'.repeat(300)}`;
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
const fragments = sms.filter(s => text.includes(s.message) && s.message !== '');
await session.close({ signal: AbortSignal.abort() });
assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or UDH fragments to arrive');
});
});
describe('C8 (target 2) - message_payload with sm_length 0', () => {
test('a deliver_sm carrying message_payload instead of short_message', async () => {
const upstreamSession = await waitForUpstreamSession('main');
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
assert.equal(err, undefined);
assert.ok(session);
const sms: Sms[] = [];
session.on('sms', s => { sms.push(s); });
const text = 'message-payload only, sm_length 0';
const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM });
assert.equal(pushed.err, undefined, 'expected Jasmin to accept a message_payload-only deliver_sm from its connector');
const arrived = await waitFor(() => sms.find(s => s.message === text), 5000);
await session.close({ signal: AbortSignal.abort() });
// Jasmin relays message_payload faithfully (sm_length 0, the real text in the TLV), so the
// whole body has to reach the application from there.
assert.ok(arrived, 'expected the message_payload body to arrive as the sms text');
assert.equal(arrived.from, TO);
assert.equal(arrived.to, FROM);
});
});
describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () => {
test('a receipt thrown as data_sm reaches the dlr event', async t => {
await waitForUpstreamSession('datasm');
const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const dlrs: Dlr[] = [];
const incomingDataSm: PduObject[] = [];
session.on('dlr', dlr => { dlrs.push(dlr); });
session.on('incomingPduObj', pduObj => { if (pduObj.cmdName === 'data_sm') incomingDataSm.push(pduObj); });
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'data_sm dlr test', to: TO });
assert.equal(sent.err, undefined);
const [smsId] = sent.smsIds;
const arrived = await waitFor(() => incomingDataSm[0], 15_000);
assert.ok(smsId);
assert.ok(arrived, 'expected Jasmin to throw the receipt as data_sm (dlr_pdu = data_sm)');
const dlr = await waitFor(() => dlrs.find(one => one.smsId === smsId), 10_000);
assert.ok(dlr, `no dlr for the receipt Jasmin threw as data_sm, id ${smsId}`);
assert.equal(dlr.statusMsg, 'DELIVERED');
});
});
describe('C11 - bind refusal and reconnect backoff', () => {
test('wrong password: one attempt, no retry', async () => {
const refusals: { cmdStatus: unknown }[] = [];
const log = {
debug: () => undefined,
error: () => undefined,
info: (msg: string, metadata?: Record<string, boolean | number | string>) => {
if (msg === 'client - bind refused') refusals.push({ cmdStatus: metadata?.cmdStatus });
},
verbose: () => undefined,
warn: () => undefined,
};
const { err, session } = await bind(USERNAME, 'wrong-password', { log, reconnect: { maxDelay: 4000, minDelay: 1000 } });
assert.ok(err);
assert.equal(session, undefined);
await delay(2000);
assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry');
assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD');
});
test('a rebind refused after a live link drops: backs off, never floods', async t => {
const options: Parameters<typeof client>[0] = {
host: PEER_HOST,
password: PASSWORD,
port: PEER_PORT,
reconnect: { maxDelay: 4000, minDelay: 1000 },
username: USERNAME,
};
const { err, session } = await client(options);
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const disconnectedAt: number[] = [];
session.on('disconnected', () => { disconnectedAt.push(Date.now()); });
options.password = 'wrong-after-drop';
session.sock.destroy();
await delay(12_000);
assert.ok(disconnectedAt.length >= 2 && disconnectedAt.length <= 8, `expected a handful of attempts, got ${String(disconnectedAt.length)}`);
for (let i = 1; i < disconnectedAt.length; i++) {
const previous = disconnectedAt[i - 1];
const current = disconnectedAt[i];
assert.ok(previous !== undefined && current !== undefined);
assert.ok(current - previous >= 150, 'expected each retry to wait at least close to minDelay');
}
});
});
describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => {
test('flooding submits past the quota gets an err naming the status; the session stays bound; a later send works', async t => {
await waitForUpstreamSession('main');
const { err, session } = await bind(THROTTLED_USERNAME, THROTTLED_PASSWORD, { maxOutstanding: 20 });
assert.equal(err, undefined);
assert.ok(session);
closeAfter(t, session);
const results = await Promise.all(
Array.from({ length: 15 }, async (_unused, index) => session.sendSms({ from: FROM, message: `throttle-${String(index)}`, to: TO })),
);
const refused = results.filter(r => r.err !== undefined);
assert.ok(refused.length > 0, 'expected the 0.1/s quota to refuse at least one of 15 parallel sends');
assert.match(refused[0]?.err?.message ?? '', /ESME_RTHROTTLED/);
const keepalive = await session.send({ cmdName: 'enquire_link' });
assert.equal(keepalive.err, undefined);
assert.ok(keepalive.pduObj);
assert.equal(keepalive.pduObj.cmdStatus, 'ESME_ROK');
// 0.1/s is one slot every 10s, so a single fixed delay is either wasteful or flaky - polling
// finds the next open slot instead of guessing it.
const deadline = Date.now() + 25_000;
let later: Awaited<ReturnType<typeof session.sendSms>> | undefined;
while (!later && Date.now() < deadline) {
const attempt = await session.sendSms({ from: FROM, message: 'after the burst', to: TO });
if (!attempt.err) later = attempt;
else await delay(500);
}
assert.ok(later, 'expected a later send to succeed once the quota\'s next slot opened');
});
});
-328
View File
@@ -1,328 +0,0 @@
import assert from 'node:assert/strict';
import net from 'node:net';
import test, { after, describe } from 'node:test';
import type { Session } from '../src/session/session.ts';
import type { Sms } from '../src/session/sms.ts';
import { PduRefusedError } from '../src/index.ts';
import { bareTlvHeader, pduBytes } from '../test/raw-pdus.ts';
import { server } from '../src/server/server.ts';
const JSMPP_HOST = process.env.JSMPP_HOST ?? 'jsmpp:8080';
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;
}
type DriverResult = Record<string, unknown>;
/** The jsmpp driver's own HTTP command channel - one bound session or raw socket per `session`/`raw` name. */
async function driver(path: string, params: Record<string, string> = {}): Promise<DriverResult> {
const url = `http://${JSMPP_HOST}${path}?${new URLSearchParams(params).toString()}`;
const response = await fetch(url);
return response.json() as Promise<DriverResult>;
}
const allSms: { session: Session; sms: Sms }[] = [];
const allSessionErrors: { err: Error; session: Session }[] = [];
const bindPdus: Record<string, unknown>[] = [];
/** Messages a test answers itself (a refusing status, or asserting on the response) - populate
* before triggering the submit that will carry this exact text, so the global auto-ack never runs. */
const manualTexts = new Set<string>();
const { err: serverErr, server: smpp } = await server({
authenticate: () => true,
idleTimeout: 40_000,
port: SMPP_PORT,
});
assert.equal(serverErr, undefined);
assert.ok(smpp);
const smppServer = smpp;
smppServer.on('session', session => {
session.on('incomingPduObj', pduObj => {
if (pduObj.cmdName.startsWith('bind_')) bindPdus.push(pduObj.params);
});
session.on('sms', sms => {
allSms.push({ session, sms });
if (!manualTexts.has(sms.message)) void sms.sendResp();
});
session.on('sessionError', err => { allSessionErrors.push({ err, session }); });
});
after(async () => {
await smppServer.close();
});
async function waitForSms(message: string, budget = 8000): Promise<Sms> {
const found = await waitFor(() => allSms.find(entry => entry.sms.message === message)?.sms, budget);
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived (seen: ${JSON.stringify(allSms.map(e => e.sms.message))})`);
return found;
}
async function waitForSessions(count: number, budget = 15_000): Promise<Session[]> {
const found = await waitFor(() => ([...smppServer.sessions].length >= count ? [...smppServer.sessions] : undefined), budget);
assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`);
return found;
}
describe('bind version negotiation (target 8)', () => {
test('0x34: our bind_resp carries sc_interface_version, jsmpp negotiates 3.4', async () => {
const result = await driver('/bind', { interfaceVersion: '52', password: 'jsmpppw', session: 'v34', systemId: 'jsmpp-v34' });
assert.equal(result.ok, true);
assert.equal(result.negotiatedInterfaceVersion, 52);
await waitForSessions(1);
const bind = await waitFor(() => bindPdus.find(p => p.system_id === 'jsmpp-v34'));
assert.ok(bind);
assert.equal(bind.interface_version, 0x34);
});
test('0x33: our bind_resp carries no TLVs, and jsmpp reports back what it asked for', async () => {
const result = await driver('/bind', { interfaceVersion: '51', password: 'jsmpppw', session: 'v33', systemId: 'jsmpp-v33' });
assert.equal(result.ok, true);
// jsmpp's session.getInterfaceVersion() simply echoes what the driver declared - it does not
// visibly fall back to 3.4 here despite the absent sc_interface_version TLV, which is the
// opposite of what its own source (a scVersion-null branch defaulting to IF_34) suggests.
// Recorded as observed rather than as a confirmation of that code path - see Peer quirks.
assert.equal(result.negotiatedInterfaceVersion, 51);
const bind = await waitFor(() => bindPdus.find(p => p.system_id === 'jsmpp-v33'));
assert.ok(bind);
assert.equal(bind.interface_version, 0x33);
const session = [...smppServer.sessions].find(s => s.peerInterfaceVersion === 0x33);
assert.ok(session);
assert.equal(session.acceptsOptionalParams(), false);
});
});
describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => {
test('UDH, 8-bit reference: one reassembled sms, each segment answered <base>-<n>', async () => {
await waitForSessions(1);
const text = 'u8-'.padEnd(200, 'a');
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'udh8', session: 'v34', text, to: '2001' });
assert.equal(result.ok, true);
const segments = result.segments as { messageId: string; part: number; total: number }[];
assert.equal(segments.length, 2);
const sms = await waitForSms(text, 15_000);
assert.equal(sms.answeredOnArrival, true);
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
});
test('UDH, 16-bit reference: also reassembled - our library reads both widths', async () => {
await waitForSessions(1);
const text = 'u16-'.padEnd(200, 'b');
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'udh16', session: 'v34', text, to: '2001' });
assert.equal(result.ok, true);
const segments = result.segments as { messageId: string }[];
const sms = await waitForSms(text, 15_000);
assert.equal(sms.answeredOnArrival, true);
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
});
test('message_payload: one sms, the full text', async () => {
await waitForSessions(1);
// No underscore: GSM 03.38's default alphabet maps ASCII 0x5F to section-sign, not "_" -
// the driver's "gsm7" mode sends plain ASCII bytes, so a real underscore round-trips wrong
// on purpose (a GSM7 encoder bug in this fixture, not in @larvit/smpp).
const text = 'payload carries the whole body in one submit sm';
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'payload', session: 'v34', text, to: '2001' });
assert.equal(result.ok, true);
const sms = await waitForSms(text);
assert.equal(sms.message, text);
assert.equal(sms.answeredOnArrival, false);
});
test('sar_* (target 3): one reassembled sms, each segment answered <base>-<n>', async () => {
await waitForSessions(1);
const text = 'sar-'.padEnd(200, 'c');
const result = await driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'sar', session: 'v34', text, to: '2001' });
assert.equal(result.ok, true);
const segments = result.segments as { messageId: string; part: number }[];
assert.equal(segments.length, 2);
const sms = await waitForSms(text, 15_000);
assert.equal(sms.answeredOnArrival, true);
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
// Neither ~130-char slice ever reached the application on its own.
assert.equal(allSms.filter(entry => text.includes(entry.sms.message)).length, 1);
});
});
describe('S3 - known-but-unhandled and malformed commands (targets 1, 6)', () => {
test('query_sm, cancel_sm, replace_sm: ESME_RINVCMDID, link survives, jsmpp accepts the answer', async () => {
await waitForSessions(1);
const errorsBefore = allSessionErrors.length;
for (const path of ['/querySm', '/cancelSm', '/replaceSm']) {
const result = await driver(path, { messageId: '1', session: 'v34' });
assert.equal(result.ok, true);
assert.equal(result.refused, true);
assert.equal(result.commandStatus, 0x0003);
}
// jsmpp itself did not throw or close the link over any of the three refusals.
const link = await driver('/enquireLink', { session: 'v34' });
assert.equal(link.ok, true);
assert.equal(link.sessionState, 'BOUND_TRX');
assert.equal(allSessionErrors.length, errorsBefore);
});
test('an unknown command id gets generic_nack ESME_RINVCMDID, and the link survives', async () => {
await driver('/rawBind', { interfaceVersion: '52', password: 'rawpw', raw: 'malformed', systemId: 'jsmpp-raw' });
const result = await driver('/rawUnknownCommand', { raw: 'malformed' });
const response = result.response as Record<string, unknown>;
assert.equal(response.cmdIdHex, '0x80000000');
assert.equal(response.cmdStatusHex, '0x3');
const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError
&& e.err.reason === 'command'));
assert.ok(refused);
const link = await driver('/rawEnquireLink', { raw: 'malformed' });
const linkResponse = link.response as Record<string, unknown>;
assert.equal(linkResponse.cmdStatusHex, '0x0');
});
test('a deliver_sm with a truncated TLV stream gets ESME_RINVTLVSTREAM, link survives', async () => {
const result = await driver('/rawTruncatedTlv', { raw: 'malformed' });
const response = result.response as Record<string, unknown>;
assert.equal(response.cmdIdHex, '0x80000005');
assert.equal(response.cmdStatusHex, '0xc0');
const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError && e.err.reason === 'tlvs'));
assert.ok(refused);
});
test('a deliver_sm whose body is shorter than sm_length declares gets ESME_RINVCMDLEN', async () => {
const result = await driver('/rawShortBody', { raw: 'malformed' });
const response = result.response as Record<string, unknown>;
assert.equal(response.cmdIdHex, '0x80000005');
assert.equal(response.cmdStatusHex, '0x2');
const link = await driver('/rawEnquireLink', { raw: 'malformed' });
const linkResponse = link.response as Record<string, unknown>;
assert.equal(linkResponse.cmdStatusHex, '0x0');
});
// Not reachable through jsmpp's own typed API at all (it cannot construct wire garbage), so this
// is a raw fixture opened directly against our server().
test('a deliver_sm ending in a bare TLV header gets ESME_RINVTLVSTREAM, and reaches no listener', async t => {
await waitForSessions(1);
const sock = net.connect(SMPP_PORT, '127.0.0.1');
t.after(() => { sock.destroy(); });
await new Promise<void>(resolve => { sock.once('connect', () => { resolve(); }); });
sock.write(pduBytes({
cmdName: 'bind_transceiver',
params: { interface_version: 0x34, password: 'pw', system_id: 'rawverify' },
seqNr: 1,
}));
await new Promise<void>(resolve => { sock.once('data', () => { resolve(); }); });
const responsePromise = new Promise<Buffer>(resolve => { sock.once('data', data => { resolve(data); }); });
sock.write(bareTlvHeader({
cmdName: 'deliver_sm',
params: {
destination_addr: 'raw2-to',
short_message: 'truncated tlv probe silent',
source_addr: 'raw2-from',
},
seqNr: 777,
}));
const response = await responsePromise;
assert.equal(response.readUInt32BE(4), 0x80000005);
assert.equal(response.readUInt32BE(8), 0x000000C0);
assert.equal(response.readUInt32BE(12), 777);
const refused = await waitFor(() => allSessionErrors.find(e => e.err instanceof PduRefusedError
&& e.err.header.seqNr === 777));
assert.ok(refused);
assert.ok(refused.err instanceof PduRefusedError);
assert.equal(refused.err.reason, 'tlvs');
assert.equal(allSms.some(entry => entry.sms.message === 'truncated tlv probe silent'), false);
});
});
describe('a refusing status is surfaced back to jsmpp', () => {
test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches jsmpp as a NegativeResponseException', async () => {
await waitForSessions(1);
const text = 'refuse-me';
manualTexts.add(text);
const submitted = driver('/submit', { encoding: 'gsm7', from: '1001', mode: 'plain', session: 'v34', text, to: '2001' });
const sms = await waitForSms(text);
await sms.sendResp({ status: 'ESME_RMSGQFUL' });
const result = await submitted;
assert.equal(result.ok, true);
assert.equal(result.refused, true);
assert.equal(result.commandStatusHex, '0x' + (0x00000014).toString(16));
});
});
-637
View File
@@ -1,637 +0,0 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import test, { after, describe } from 'node:test';
import type { MessageState } from '../src/codec/constants.ts';
import type { Dlr } from '../src/protocol/dlr.ts';
import type { Session } from '../src/session/session.ts';
import type { Sms } from '../src/session/sms.ts';
import { ConcatReference } from '../src/protocol/udh.ts';
import { consts } from '../src/codec/constants.ts';
import { detect, encodings } from '../src/codec/encodings.ts';
import { paramText } from '../src/codec/types.ts';
import { server } from '../src/server/server.ts';
import { splitMessage } from '../src/message.ts';
import { submitSmParams } from '../src/messages/submit.ts';
// smsbox HTTP hosts, one per variant - all point at the same node:2775 SMPP server.
const MAIN_SMSBOX = process.env.MAIN_SMSBOX ?? 'kannel-smsbox:13013';
const IV33_SMSBOX = process.env.IV33_SMSBOX ?? 'kannel-iv33-smsbox:13013';
const MAXP1_SMSBOX = process.env.MAXP1_SMSBOX ?? 'kannel-maxp1-smsbox:13013';
const NOTRX_SMSBOX = process.env.NOTRX_SMSBOX ?? 'kannel-notrx-smsbox:13013';
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
const CALLBACK_PORT = Number(process.env.CALLBACK_PORT ?? '8080');
const SENDSMS_USER = 'tester';
const SENDSMS_PASS = 'testerpw';
type Variant = 'iv33' | 'main' | 'maxp1' | 'notrx';
function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); });
}
/** Polls until `get()` stops returning undefined, or the budget runs out. */
async function waitFor<T>(get: () => T | undefined, budget = 5000): Promise<T | undefined> {
const deadline = Date.now() + budget;
let value = get();
while (value === undefined && Date.now() < deadline) {
await delay(20);
value = get();
}
return value;
}
// --- Shared infra: one long-lived server() and one HTTP callback listener for the whole file,
// since every Kannel variant dials in and keeps retrying from container start, independent of
// when this file's tests run. ---
type MoCallback = { coding: string; from: string; text: string; to: string; udh: string };
type DlrCallback = { answer: string; id: string; type: string };
const moCallbacks = new Map<Variant, MoCallback[]>();
const dlrCallbacks: DlrCallback[] = [];
function variantFromPath(pathname: string): Variant | undefined {
if (pathname === '/mo') return 'main';
if (pathname === '/mo/iv33') return 'iv33';
if (pathname === '/mo/maxp1') return 'maxp1';
if (pathname === '/mo/notrx') return 'notrx';
return undefined;
}
function rawQueryValue(rawUrl: string, key: string): string {
const match = new RegExp(`[?&]${key}=([^&]*)`).exec(rawUrl);
return match?.[1] ?? '';
}
function percentDecodeBytes(raw: string): Buffer {
const bytes: number[] = [];
for (let i = 0; i < raw.length; i++) {
if (raw[i] === '%' && i + 2 < raw.length) {
bytes.push(Number.parseInt(raw.slice(i + 1, i + 3), 16));
i += 2;
} else {
bytes.push(raw.charCodeAt(i));
}
}
return Buffer.from(bytes);
}
// Kannel's %a decodes GSM text to a normal string before percent-escaping it, but for UCS-2
// (coding=2) it escapes the raw big-endian bytes instead - URLSearchParams decodes percent-escapes
// as UTF-8, which turns those raw bytes into mojibake, so coding=2 needs a byte-level percent-decode
// through our own UCS2 decoder instead.
function moText(rawUrl: string, url: URL): string {
if (url.searchParams.get('coding') !== '2') return url.searchParams.get('text') ?? '';
return encodings.UCS2.decode(percentDecodeBytes(rawQueryValue(rawUrl, 'text')));
}
const httpServer = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://node');
if (url.pathname === '/dlr') {
dlrCallbacks.push({
answer: url.searchParams.get('answer') ?? '',
id: url.searchParams.get('id') ?? '',
type: url.searchParams.get('type') ?? '',
});
res.writeHead(200);
res.end();
return;
}
const variant = variantFromPath(url.pathname);
if (variant) {
const list = moCallbacks.get(variant) ?? [];
list.push({
coding: url.searchParams.get('coding') ?? '',
from: url.searchParams.get('from') ?? '',
text: moText(req.url ?? '', url),
to: url.searchParams.get('to') ?? '',
udh: url.searchParams.get('udh') ?? '',
});
moCallbacks.set(variant, list);
res.writeHead(200);
res.end();
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>(resolve => { httpServer.listen(CALLBACK_PORT, resolve); });
function variantFromSystemId(systemId: string): Variant | undefined {
if (systemId === 'kannel') return 'main';
if (systemId === 'kannel-iv33') return 'iv33';
if (systemId === 'kannel-maxp1') return 'maxp1';
if (systemId === 'kannel-notrx') return 'notrx';
return undefined;
}
const allSms: { sms: Sms; variant: Variant }[] = [];
const allDlrs: { dlr: Dlr; variant: Variant }[] = [];
const bindPdus: { params: Record<string, unknown>; variant: Variant }[] = [];
const { err: serverErr, server: smpp } = await server({
authenticate: ({ password, systemId }) => {
if (password !== 'kannelpw') return false;
const variant = variantFromSystemId(systemId);
return variant ? { userData: { variant } } : false;
},
idleTimeout: 40_000,
port: SMPP_PORT,
});
assert.equal(serverErr, undefined);
assert.ok(smpp);
const smppServer = smpp;
smppServer.on('session', session => {
session.on('incomingPduObj', pduObj => {
if (!pduObj.cmdName.startsWith('bind_')) return;
const variant = variantFromSystemId(paramText(pduObj.params.system_id));
if (variant) bindPdus.push({ params: pduObj.params, variant });
});
session.on('sms', sms => {
const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
if (variant) allSms.push({ sms, variant });
});
session.on('dlr', dlr => {
const variant = (session.userData as { variant?: Variant } | undefined)?.variant;
if (variant) allDlrs.push({ dlr, variant });
});
});
after(async () => {
await smppServer.close();
await new Promise<void>(resolve => { httpServer.close(() => { resolve(); }); });
});
function sessionsFor(variant: Variant): Session[] {
return [...smppServer.sessions].filter(s => (s.userData as { variant?: Variant } | undefined)?.variant === variant);
}
async function waitForSessions(variant: Variant, count: number, budget = 15_000): Promise<Session[]> {
const found = await waitFor(() => (sessionsFor(variant).length >= count ? sessionsFor(variant) : undefined), budget);
assert.ok(found, `no ${String(count)} session(s) bound for variant ${variant} within ${String(budget)}ms`);
return found;
}
// Kannel's sendsms answers 202 with a body of "0: Accepted for delivery" or "3: Queued for later
// delivery" - the HTTP status is never 200 (Table 7-16 of the user guide).
async function sendsms(host: string, params: Record<string, string>): Promise<{ body: string; status: number }> {
const url = new URL(`http://${host}/cgi-bin/sendsms`);
url.search = new URLSearchParams({ password: SENDSMS_PASS, username: SENDSMS_USER, ...params }).toString();
const response = await fetch(url);
const body = await response.text();
assert.equal(response.status, 202);
assert.match(body, /^[03]: /);
return { body, status: response.status };
}
/** The next incoming `sms` for a variant carrying `message`, polling past ones that don't match. */
async function waitForSms(variant: Variant, message: string, budget = 8000): Promise<Sms> {
const found = await waitFor(
() => allSms.find(entry => entry.variant === variant && entry.sms.message === message)?.sms,
budget,
);
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for variant ${variant}`);
return found;
}
async function waitForMoCallback(variant: Variant, text: string, budget = 8000): Promise<MoCallback> {
const found = await waitFor(
() => moCallbacks.get(variant)?.find(callback => callback.text === text),
budget,
);
assert.ok(found, `no MO callback carrying ${JSON.stringify(text)} arrived for variant ${variant}`);
return found;
}
async function waitForDlrCallback(id: string, type: string, budget = 8000): Promise<DlrCallback> {
const found = await waitFor(
() => dlrCallbacks.find(callback => callback.id === id && callback.type === type),
budget,
);
assert.ok(found, `no dlr callback id=${id} type=${type} arrived (seen: ${JSON.stringify(dlrCallbacks)})`);
return found;
}
/** Builds a `deliver_sm` per segment the way `session.sendSms()` builds `submit_sm` - see the MO
* describe block for why this bypasses `sendSms()` itself. */
async function sendMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
const encoding = detect(opts.message);
const reference = moReference.next();
const segments = splitMessage(opts.message, { encoding, reference });
const multipart = segments.length > 1;
for (const segment of segments) {
const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding, multipart });
const sent = await session.send({ cmdName: 'deliver_sm', params });
assert.equal(sent.err, undefined);
assert.ok(sent.pduObj);
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
}
}
const moReference = new ConcatReference();
describe('kannel main variant - bind', () => {
test('binds transceiver 34, defaults addr_ton/npi to 0, carries our system_type', async () => {
await waitForSessions('main', 1);
const bind = await waitFor(() => bindPdus.find(entry => entry.variant === 'main'));
assert.ok(bind);
assert.equal(bind.params.system_type, 'kannel-esme');
assert.equal(bind.params.interface_version, 0x34);
assert.equal(bind.params.addr_ton, 0);
assert.equal(bind.params.addr_npi, 0);
assert.equal(bind.params.address_range, '');
});
});
describe('S1 - MT from Kannel with delivery reports', () => {
for (const status of ['DELIVERED', 'UNDELIVERABLE', 'EXPIRED', 'ENROUTE'] as MessageState[]) {
test(`dlr-mask=31 round trip settles as ${status}`, async () => {
await waitForSessions('main', 1);
const text = `s1-${status.toLowerCase()}`;
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
await sendsms(MAIN_SMSBOX, {
'dlr-mask': '31',
'dlr-url': dlrUrl,
from: '46701113311',
text,
to: '46709771337',
});
const sms = await waitForSms('main', text);
assert.equal(sms.from, '46701113311');
assert.equal(sms.to, '46709771337');
assert.equal(sms.dlr, true);
assert.equal((await sms.sendResp()).err, undefined);
// dlr-mask bit 8: Kannel fires this off the submit_sm_resp alone, before any receipt.
const submitAck = await waitForDlrCallback(sms.smsId, '8');
assert.equal(submitAck.id, sms.smsId);
const report = await sms.sendDlr(status);
assert.equal(report.err, undefined);
// Kannel's %d for a settled message: DELIVERED 1, ENROUTE 4, UNDELIVERABLE 2 - but EXPIRED
// is its own bit (34 = 32|2), not folded into the generic failure code.
const finalType = status === 'DELIVERED' ? '1' : status === 'ENROUTE' ? '4' : status === 'EXPIRED' ? '34' : '2';
const final = await waitForDlrCallback(sms.smsId, finalType);
assert.equal(final.id, sms.smsId);
});
}
});
describe('long MT from Kannel', () => {
test('300-char GSM text reassembles whole', async () => {
await waitForSessions('main', 1);
const text = 'g'.repeat(300);
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text, 15_000);
assert.equal(sms.message, text);
assert.equal((await sms.sendResp()).err, undefined);
});
test('UCS-2 text with 一 and an emoji reassembles whole', async () => {
await waitForSessions('main', 1);
const text = `一😀${'x'.repeat(140)}`;
await sendsms(MAIN_SMSBOX, { charset: 'UTF-8', coding: '2', from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text, 15_000);
assert.equal(sms.message, text);
assert.equal((await sms.sendResp()).err, undefined);
});
});
describe('S11 - GSM extension characters', () => {
test('€ [ ] ~ round trip through Kannel unpacked GSM7', async () => {
await waitForSessions('main', 1);
const text = '€[]~ok';
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text);
assert.equal(sms.message, text);
assert.equal((await sms.sendResp()).err, undefined);
});
});
describe('MO to Kannel', () => {
test('session.sendSms() is refused by Kannel: submit_sm only flows ESME to SMSC', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const result = await session.sendSms({ from: '46701113311', message: 'mo via sendSms', to: '46709771337' });
assert.ok(result.err);
assert.match(result.err.message, /ESME_RINVCMDID/);
});
test('a deliver_sm carrying a single-segment GSM message reaches the sms-service once', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const text = 'mo single segment';
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
const callback = await waitForMoCallback('main', text);
// Two Kannel quirks, not this library's: %p prepends '+' to an international-TON address
// even though the wire address carried none, and %P reports smsbox's own `global-sender`
// rather than the deliver_sm's destination_addr (unset `my-number` on the smsc group).
assert.equal(callback.from, '+46709771337');
assert.equal(callback.to, '46700000000');
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
assert.equal(matching.length, 1);
});
test('a deliver_sm split over 3 GSM segments reaches the sms-service once, whole', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const text = 'm'.repeat(400);
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
const callback = await waitForMoCallback('main', text, 15_000);
assert.equal(callback.text, text);
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
assert.equal(matching.length, 1);
});
test('a deliver_sm split over UCS-2 segments reaches the sms-service once, whole', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const text = `一😀${'y'.repeat(200)}`;
await sendMo(session, { from: '46709771337', message: text, to: '46701113311' });
const callback = await waitForMoCallback('main', text, 15_000);
assert.equal(callback.text, text);
const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? [];
assert.equal(matching.length, 1);
});
});
describe('S6 - wait-ack expiry and keepalive', () => {
// Runs before the wait-ack test below, which deliberately provokes a disconnect/reconnect on
// this same variant's session - a stable link is needed to observe the keepalive cleanly.
test('enquire_link every 5s keeps a 60s-idle session from hitting idleTimeout (40s)', async () => {
const [session] = await waitForSessions('main', 1);
assert.ok(session);
const closes: unknown[] = [];
session.on('close', () => { closes.push(undefined); });
await delay(60_000);
assert.deepEqual(closes, []);
});
test('a submit_sm answered past wait-ack (5s) - Kannel\'s reaction is recorded, not judged', async () => {
await waitForSessions('main', 1);
const text = 's6-slow-resp';
const before = allSms.filter(e => e.variant === 'main').length;
await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('main', text);
await delay(7000);
await sms.sendResp().catch(() => undefined);
// wait-ack-expire defaults to 0x00 (disconnect/reconnect); reconnect-delay is 1s, so give it
// room to rebind and possibly resend the same submit_sm on the new session.
await delay(4000);
const after = allSms.filter(e => e.variant === 'main' && e.sms.message === text);
assert.ok(after.length >= 1, 'the original sms is still on record');
// Recorded for findings, not asserted: whether a resend duplicated the sms is peer behaviour.
void before;
});
});
describe('iv33 variant - interface_version 0x33', () => {
test('binds at 0x33 and negotiates no optional params', async () => {
const [session] = await waitForSessions('iv33', 1);
assert.ok(session);
assert.equal(session.peerInterfaceVersion, 0x33);
assert.equal(session.acceptsOptionalParams(), false);
});
test('MT + DLR round trip still correlates with no TLVs on the receipt', async () => {
const [session] = await waitForSessions('iv33', 1);
assert.ok(session);
assert.equal(session.acceptsOptionalParams(), false);
const text = 'iv33 round trip';
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
await sendsms(IV33_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('iv33', text);
assert.equal((await sms.sendResp()).err, undefined);
await waitForDlrCallback(sms.smsId, '8');
await sms.sendDlr('DELIVERED');
await waitForDlrCallback(sms.smsId, '1');
});
});
describe('maxp1 variant - max-pending-submits 1', () => {
test('a burst of 20 sendsms calls all arrive, in order, all answered', async () => {
const [session] = await waitForSessions('maxp1', 1);
assert.ok(session);
// max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a
// time - answer each as it lands, or the whole burst stalls behind the first message.
session.on('sms', sms => { void sms.sendResp(); });
const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`);
// Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever
// order the OS schedules them, so only a request-then-response chain keeps send order
// meaningful - max-pending-submits=1 is exercised regardless, since 20 calls in a tight loop
// still outrun one-at-a-time SMPP submission.
for (const text of texts) {
await sendsms(MAXP1_SMSBOX, { from: '46701113311', text, to: '46709771337' });
}
const arrived = await waitFor(() => {
const got = allSms.filter(e => e.variant === 'maxp1').map(e => e.sms.message);
return texts.every(text => got.includes(text)) ? got : undefined;
}, 20_000);
assert.ok(arrived, 'not all 20 burst messages arrived');
const ordered = allSms.filter(e => e.variant === 'maxp1').map(e => e.sms.message).filter(m => texts.includes(m));
assert.deepEqual(ordered, texts);
});
});
describe('notrx variant - separate TX and RX binds', () => {
test('Kannel opens a transmitter bind and a receiver bind, both accepted', async () => {
const sessions = await waitForSessions('notrx', 2);
assert.deepEqual(sessions.map(s => s.boundAs).sort(), ['receiver', 'transmitter']);
});
test('submit_sm from Kannel arrives on the transmitter bind, is answered, nothing refused', async () => {
const sessions = await waitForSessions('notrx', 2);
const tx = sessions.find(s => s.boundAs === 'transmitter');
assert.ok(tx);
const text = 'notrx mt';
await sendsms(NOTRX_SMSBOX, { from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('notrx', text);
assert.equal(sms.session, tx);
assert.equal((await sms.sendResp()).err, undefined);
});
test('a receipt built on the receiver bind reaches Kannel; the transmitter bind cannot carry one', async () => {
const sessions = await waitForSessions('notrx', 2);
const tx = sessions.find(s => s.boundAs === 'transmitter');
const rx = sessions.find(s => s.boundAs === 'receiver');
assert.ok(tx);
assert.ok(rx);
// sms.sendDlr() ties the receipt to the session the submit_sm arrived on (the TX bind), which
// cannot carry deliver_sm at all (see README, Bind direction) - documented behaviour, not a
// defect. A split-bind peer's receipt has to be sent on the RX session directly.
//
// session.send() is the library's unchecked raw passthrough (README), so this probes Kannel's
// own direction enforcement, not ours: Kannel answers ESME_ROK to a deliver_sm on its
// transmitter bind rather than refusing it - recorded as a peer quirk, not asserted as a spec
// violation this library must guard against.
const onTx = await tx.send({
cmdName: 'deliver_sm',
params: { destination_addr: '46701113311', short_message: 'nope', source_addr: '46709771337' },
});
assert.equal(onTx.err, undefined);
const text = 'notrx dlr target';
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`;
await sendsms(NOTRX_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337' });
const sms = await waitForSms('notrx', text);
assert.equal((await sms.sendResp()).err, undefined);
await waitForDlrCallback(sms.smsId, '8');
const receiptDate = '2609051200';
const receiptSent = await rx.send({
cmdName: 'deliver_sm',
params: {
destination_addr: sms.from,
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
short_message: `id:${sms.smsId} sub:001 dlvrd:001 submit date:${receiptDate} done date:${receiptDate} `
+ 'stat:DELIVRD err:000 text:',
source_addr: sms.to,
},
});
assert.equal(receiptSent.err, undefined);
assert.ok(receiptSent.pduObj);
assert.equal(receiptSent.pduObj.cmdStatus, 'ESME_ROK');
await waitForDlrCallback(sms.smsId, '1');
});
test('MO built on the receiver bind reaches the sms-service', async () => {
const sessions = await waitForSessions('notrx', 2);
const rx = sessions.find(s => s.boundAs === 'receiver');
assert.ok(rx);
const text = 'notrx mo on rx';
await sendMo(rx, { from: '46709771337', message: text, to: '46701113311' });
const callback = await waitForMoCallback('notrx', text);
assert.equal(callback.text, text);
});
});
@@ -1,55 +0,0 @@
# cloudhopper-smpp (the fizzed fork) has no published artifact; cloned at a fixed commit and
# installed into the local Maven repo, which the driver build below then depends on.
FROM maven:3.9.11-eclipse-temurin-21 AS cloudhopper-source
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
ARG CLOUDHOPPER_COMMIT=ae6485a86c968d344bebf0fa180928905b4a8ad1
# The parent pom (fizzed-maven-parent:1.15) hardcodes source/target 1.7 in its own compiler-plugin
# config, which -Dmaven.compiler.source cannot override; ch-smpp's own pom declares no <build> of
# its own, so injecting one here (source/target 8, otherwise unmodified) is the narrowest override.
RUN git clone https://github.com/fizzed/cloudhopper-smpp.git /src \
&& cd /src \
&& git checkout "${CLOUDHOPPER_COMMIT}" \
&& sed -i 's#</project>#<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><configuration><source>8</source><target>8</target></configuration></plugin></plugins></build></project>#' pom.xml \
&& mvn -q install -Dmaven.test.skip=true -Dgpg.skip=true -Dmaven.javadoc.skip=true
# A self-signed cert this image trusts, generated at build time - never committed. server.key/crt
# are PEM (for our server()'s own tls option); truststore.jks is what the driver's SSL client trusts.
FROM eclipse-temurin:21.0.8_9-jre-jammy AS certs
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /certs \
&& openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
-keyout /certs/server.key -out /certs/server.crt -subj "/CN=interop-cloudhopper" \
&& keytool -importcert -noprompt -alias interop -file /certs/server.crt \
-keystore /certs/truststore.jks -storepass changeit \
&& openssl pkcs12 -export -in /certs/server.crt -inkey /certs/server.key \
-out /certs/keystore.p12 -name interop -password pass:changeit
FROM maven:3.9.11-eclipse-temurin-21 AS build
COPY --from=cloudhopper-source /root/.m2 /root/.m2
WORKDIR /driver
COPY pom.xml .
COPY src ./src
RUN mvn -q package -DskipTests
FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime
WORKDIR /app
COPY --from=build /driver/target/driver.jar ./driver.jar
COPY --from=certs /certs /certs
EXPOSE 8080
# node's test file needs the same server.key/server.crt to configure its own tls option; the
# S10 compose overlay mounts a shared volume at /shared-certs on both this service and node.
ENTRYPOINT ["sh", "-c", "cp /certs/server.key /certs/server.crt /shared-certs/ 2>/dev/null && chmod 644 /shared-certs/server.key /shared-certs/server.crt || true; exec java -jar driver.jar \"$@\"", "--"]
CMD ["node", "2775"]
-52
View File
@@ -1,52 +0,0 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>se.larvit.interop</groupId>
<artifactId>cloudhopper-driver</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.fizzed</groupId>
<artifactId>ch-smpp</artifactId>
<version>5.0.10-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.36</version>
</dependency>
</dependencies>
<build>
<finalName>driver</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>smpp.interop.cloudhopper.Driver</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,372 +0,0 @@
package smpp.interop.cloudhopper;
import com.cloudhopper.smpp.SmppBindType;
import com.cloudhopper.smpp.SmppSession;
import com.cloudhopper.smpp.SmppSessionConfiguration;
import com.cloudhopper.smpp.impl.DefaultSmppClient;
import com.cloudhopper.smpp.impl.DefaultSmppSessionHandler;
import com.cloudhopper.smpp.pdu.PduRequest;
import com.cloudhopper.smpp.pdu.SubmitSm;
import com.cloudhopper.smpp.pdu.SubmitSmResp;
import com.cloudhopper.smpp.ssl.SslConfiguration;
import com.cloudhopper.smpp.type.Address;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.atomic.AtomicInteger;
/**
* An HTTP-driven Cloudhopper ESME: bind with a chosen window configuration, then fire concurrent
* submits against our (possibly deliberately slow) server and report per-request outcomes and the
* observed send-window occupancy, so the node test file can assert none lost, none duplicated.
*/
public final class Driver {
private static final Logger log = LoggerFactory.getLogger(Driver.class);
private static final Map<String, SmppSession> sessions = new ConcurrentHashMap<>();
private static final AtomicInteger expiredCount = new AtomicInteger(0);
private static DefaultSmppClient client;
private static ScheduledThreadPoolExecutor monitorExecutor;
private static ExecutorService ioExecutor;
private static String host;
private static int port;
private Driver() { }
public static void main(String[] args) throws IOException {
host = args.length > 0 ? args[0] : "node";
port = args.length > 1 ? Integer.parseInt(args[1]) : 2775;
ioExecutor = Executors.newCachedThreadPool();
monitorExecutor = new ScheduledThreadPoolExecutor(2);
client = new DefaultSmppClient(Executors.newCachedThreadPool(), 50, monitorExecutor);
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/health", exchange -> respond(exchange, 200, "{\"ok\":true}"));
server.createContext("/bind", Driver::handleBind);
server.createContext("/unbind", Driver::handleUnbind);
server.createContext("/submit", Driver::handleSubmit);
server.createContext("/load", Driver::handleLoad);
server.createContext("/windowBurst", Driver::handleWindowBurst);
server.createContext("/sendWindowSize", Driver::handleSendWindowSize);
server.setExecutor(null);
server.start();
System.out.println("cloudhopper driver listening on 8080, target " + host + ":" + port);
}
// --- HTTP plumbing (same shape as the jsmpp driver's, kept independent on purpose) ---
private static Map<String, String> queryParams(HttpExchange exchange) {
Map<String, String> params = new LinkedHashMap<>();
String query = exchange.getRequestURI().getRawQuery();
if (query == null) return params;
for (String pair : query.split("&")) {
int eq = pair.indexOf('=');
String key = eq < 0 ? pair : pair.substring(0, eq);
String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
params.put(key, value);
}
return params;
}
private static void respond(HttpExchange exchange, int status, String body) {
try {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
} catch (IOException e) {
// The client gave up reading; nothing left to answer.
}
}
private static void respondOk(HttpExchange exchange, Map<String, Object> result) {
respond(exchange, 200, Json.write(result));
}
private static void respondErr(HttpExchange exchange, Exception e) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", false);
result.put("errorClass", e.getClass().getName());
result.put("error", String.valueOf(e.getMessage()));
respond(exchange, 200, Json.write(result));
}
// --- handlers ---
private static void handleBind(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
String name = p.getOrDefault("session", "default");
try {
SmppSessionConfiguration config = new SmppSessionConfiguration();
config.setName(name);
config.setType(SmppBindType.TRANSCEIVER);
config.setHost(p.getOrDefault("host", host));
config.setPort(Integer.parseInt(p.getOrDefault("port", String.valueOf(port))));
config.setConnectTimeout(10_000);
config.setSystemId(p.getOrDefault("systemId", "cloudhopper"));
config.setPassword(p.getOrDefault("password", "chpw"));
config.setWindowSize(Integer.parseInt(p.getOrDefault("windowSize", "1")));
config.setRequestExpiryTimeout(Long.parseLong(p.getOrDefault("requestExpiryTimeout", "30000")));
config.setWindowMonitorInterval(Long.parseLong(p.getOrDefault("windowMonitorInterval", "15000")));
config.setCountersEnabled(true);
if (Boolean.parseBoolean(p.getOrDefault("useSsl", "false"))) {
// Cloudhopper's SslContextFactory only skips keystore loading when *neither* store is
// configured - a trust-store-only client falls through to loadKeyStore() with a null
// path and fails, so the build-time self-signed cert also gets used as the (otherwise
// unneeded) client keystore.
SslConfiguration ssl = new SslConfiguration();
ssl.setTrustStorePath("/certs/truststore.jks");
ssl.setTrustStorePassword("changeit");
ssl.setKeyStorePath("/certs/keystore.p12");
ssl.setKeyStorePassword("changeit");
ssl.setKeyStoreType("PKCS12");
config.setUseSsl(true);
config.setSslConfiguration(ssl);
}
DefaultSmppSessionHandler handler = new DefaultSmppSessionHandler(log) {
@Override
public void firePduRequestExpired(PduRequest pduRequest) {
expiredCount.incrementAndGet();
log.warn("PDU request expired in window monitor: {}", pduRequest);
}
};
SmppSession session = client.bind(config, handler);
sessions.put(name, session);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
respondOk(exchange, result);
} catch (Exception e) {
respondErr(exchange, e);
}
}
private static void handleUnbind(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SmppSession session = sessions.remove(p.getOrDefault("session", "default"));
try {
if (session != null) {
session.unbind(5000);
session.destroy();
}
respondOk(exchange, Map.of("ok", true));
} catch (Exception e) {
respondErr(exchange, e);
}
}
private static SubmitSm buildSubmit(String from, String to, String text)
throws com.cloudhopper.smpp.type.SmppInvalidArgumentException {
SubmitSm submit = new SubmitSm();
submit.setSourceAddress(new Address((byte) 0x01, (byte) 0x01, from));
submit.setDestAddress(new Address((byte) 0x01, (byte) 0x01, to));
submit.setShortMessage(text.getBytes(StandardCharsets.US_ASCII));
return submit;
}
private static void handleSubmit(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
if (session == null) {
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
return;
}
try {
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "10000"));
long start = System.currentTimeMillis();
SubmitSmResp resp = session.submit(
buildSubmit(p.getOrDefault("from", "1000"), p.getOrDefault("to", "2000"), p.getOrDefault("text", "hi")),
timeoutMs);
long elapsed = System.currentTimeMillis() - start;
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("messageId", resp.getMessageId());
result.put("commandStatus", resp.getCommandStatus());
result.put("elapsedMs", (int) elapsed);
respondOk(exchange, result);
} catch (Exception e) {
respondErr(exchange, e);
}
}
/**
* Pushes count messages and reports only the rate. Cloudhopper's submit blocks on the response,
* so the pool size is what puts requests in flight — the same shape as the other peers' load.
*/
private static void handleLoad(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
if (session == null) {
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
return;
}
int count = Integer.parseInt(p.getOrDefault("count", "20000"));
int concurrency = Integer.parseInt(p.getOrDefault("concurrency", "50"));
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "60000"));
String from = p.getOrDefault("from", "1000");
String to = p.getOrDefault("to", "2000");
AtomicInteger issued = new AtomicInteger();
AtomicInteger failed = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
long started = System.nanoTime();
for (int worker = 0; worker < concurrency; worker++) {
pool.execute(() -> {
while (issued.getAndIncrement() < count) {
try {
session.submit(buildSubmit(from, to, "benchmark"), timeoutMs);
} catch (Exception e) {
failed.incrementAndGet();
}
}
});
}
pool.shutdown();
try {
if (!pool.awaitTermination(10, java.util.concurrent.TimeUnit.MINUTES)) pool.shutdownNow();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
double seconds = (System.nanoTime() - started) / 1e9;
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("count", count);
result.put("concurrency", concurrency);
result.put("failed", failed.get());
result.put("seconds", Math.round(seconds * 1000d) / 1000d);
result.put("perSecond", Math.round(count / seconds));
respond(exchange, 200, Json.write(result));
}
/** Fires `count` submits at once, each tagged by index in its text, to probe window pressure. */
private static void handleWindowBurst(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
if (session == null) {
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
return;
}
int count = Integer.parseInt(p.getOrDefault("count", "10"));
long timeoutMs = Long.parseLong(p.getOrDefault("timeoutMs", "60000"));
String from = p.getOrDefault("from", "1000");
String to = p.getOrDefault("to", "2000");
String prefix = p.getOrDefault("prefix", "burst");
AtomicInteger peakWindow = new AtomicInteger(0);
Thread sampler = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
int size = session.getSendWindow().getSize();
peakWindow.updateAndGet(prev -> Math.max(prev, size));
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
sampler.setDaemon(true);
sampler.start();
List<Future<Map<String, Object>>> futures = new ArrayList<>();
for (int i = 0; i < count; i++) {
int index = i;
futures.add(ioExecutor.submit((Callable<Map<String, Object>>) () -> {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("index", index);
try {
long start = System.currentTimeMillis();
SubmitSmResp resp = session.submit(buildSubmit(from, to, prefix + "-" + index), timeoutMs);
entry.put("ok", true);
entry.put("messageId", resp.getMessageId());
entry.put("elapsedMs", (int) (System.currentTimeMillis() - start));
} catch (Exception e) {
entry.put("ok", false);
entry.put("errorClass", e.getClass().getSimpleName());
entry.put("error", String.valueOf(e.getMessage()));
}
return entry;
}));
}
List<Object> results = new ArrayList<>();
for (Future<Map<String, Object>> f : futures) {
try {
results.add(f.get());
} catch (Exception e) {
results.add(Map.of("ok", false, "error", String.valueOf(e.getMessage())));
}
}
sampler.interrupt();
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("results", results);
result.put("peakWindowSize", peakWindow.get());
result.put("expiredCount", expiredCount.get());
respondOk(exchange, result);
}
private static void handleSendWindowSize(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SmppSession session = sessions.get(p.getOrDefault("session", "default"));
if (session == null) {
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
return;
}
respondOk(exchange, Map.of("ok", true, "size", session.getSendWindow().getSize()));
}
}
@@ -1,75 +0,0 @@
package smpp.interop.cloudhopper;
import java.util.List;
import java.util.Map;
/** A minimal JSON writer for the driver's own controlled output - no parsing needed. */
final class Json {
private Json() { }
static String write(Object value) {
StringBuilder sb = new StringBuilder();
writeValue(sb, value);
return sb.toString();
}
@SuppressWarnings("unchecked")
private static void writeValue(StringBuilder sb, Object value) {
if (value == null) {
sb.append("null");
} else if (value instanceof String s) {
writeString(sb, s);
} else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) {
sb.append(value);
} else if (value instanceof Map<?, ?> map) {
sb.append('{');
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!first) sb.append(',');
first = false;
writeString(sb, String.valueOf(entry.getKey()));
sb.append(':');
writeValue(sb, entry.getValue());
}
sb.append('}');
} else if (value instanceof List<?> list) {
sb.append('[');
boolean first = true;
for (Object item : list) {
if (!first) sb.append(',');
first = false;
writeValue(sb, item);
}
sb.append(']');
} else if (value instanceof byte[] bytes) {
writeString(sb, hex(bytes));
} else {
writeString(sb, String.valueOf(value));
}
}
private static void writeString(StringBuilder sb, String s) {
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
}
sb.append('"');
}
static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
}
-37
View File
@@ -1,37 +0,0 @@
# 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"]
@@ -1,37 +0,0 @@
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
@@ -1,35 +0,0 @@
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
@@ -1,35 +0,0 @@
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
@@ -1,35 +0,0 @@
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
@@ -1,23 +0,0 @@
#!/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
-146
View File
@@ -1,146 +0,0 @@
#!/usr/bin/env python3
"""Boot-time jcli bootstrap for one Jasmin instance: group, users, an smppc connector pointing at
our own server(), and the MT/MO routes that wire it up. jcli (port 8990) is a Twisted telnet
console: no negotiation reply is needed, the server proceeds regardless (confirmed empirically)."""
import os
import socket
import sys
import time
JCLI_HOST = os.environ.get('JCLI_HOST', 'jasmin')
JCLI_PORT = int(os.environ.get('JCLI_PORT', '8990'))
JCLI_USER = os.environ.get('JCLI_USER', 'jcliadmin')
JCLI_PASS = os.environ.get('JCLI_PASS', 'jclipwd')
GROUP = os.environ.get('GROUP', 'clients')
ESME_UID = os.environ.get('ESME_UID', 'esme1')
ESME_PASSWORD = os.environ.get('ESME_PASSWORD', 'esme1pw')
ESME2_UID = os.environ.get('ESME2_UID', 'esme2')
ESME2_PASSWORD = os.environ.get('ESME2_PASSWORD', 'esme2pw')
ESME2_THROUGHPUT = os.environ.get('ESME2_THROUGHPUT', '0.1')
CONNECTOR_CID = os.environ.get('CONNECTOR_CID', 'upstream')
CONNECTOR_HOST = os.environ.get('CONNECTOR_HOST', 'node')
CONNECTOR_PORT = os.environ.get('CONNECTOR_PORT', '2777')
CONNECTOR_USERNAME = os.environ.get('CONNECTOR_USERNAME', 'upstreamesme')
# <=8 chars: SMPP's password is a C-octet-string with an 8-char + NUL wire maximum, which Jasmin's
# own smpp.pdu encoder enforces strictly when it builds the connector's own bind PDU (unlike our
# library's encoder, which is permissive) - a longer one throws mid-bind on every single attempt.
CONNECTOR_PASSWORD = os.environ.get('CONNECTOR_PASSWORD', 'upstrmpw')
class Jcli:
def __init__(self, host, port):
last_err = None
for _ in range(60):
try:
self.sock = socket.create_connection((host, port), timeout=5)
self.sock.settimeout(5)
self._drain()
return
except OSError as err:
last_err = err
time.sleep(1)
raise RuntimeError(f'could not reach jcli at {host}:{port}: {last_err}')
def _drain(self, wait=0.4):
time.sleep(wait)
buf = b''
try:
while True:
chunk = self.sock.recv(65536)
if not chunk:
break
buf += chunk
except socket.timeout:
pass
return buf
def send(self, line, wait=0.4):
self.sock.sendall(line.encode() + b'\r\n')
return self._drain(wait).decode(errors='replace')
def login(self, user, password):
self._drain()
self.send(user)
reply = self.send(password)
if 'Welcome to Jasmin' not in reply:
raise RuntimeError(f'jcli login failed: {reply!r}')
def run(self, *lines, label=''):
"""Sends a sequence ending in 'ok' and fails loudly if Jasmin refused it. Checking only for
keywords missed 'Failed adding connector, check log for details' once, which left the
session stuck at the '>' sub-prompt and every later command misread as a key inside it - so
this also insists the last reply lands back on the top-level 'jcli :' prompt."""
out = []
for line in lines:
out.append(self.send(line))
joined = '\n'.join(out)
last = out[-1] if out else ''
back_at_top = last.rstrip().endswith('jcli :')
keyword_hit = any(word in joined.lower() for word in ('error', 'must set', 'unknown', 'invalid', 'failed'))
if keyword_hit or not back_at_top:
raise RuntimeError(f'{label} failed (last reply {last!r}):\n{joined}')
return joined
def main() -> int:
jcli = Jcli(JCLI_HOST, JCLI_PORT)
jcli.login(JCLI_USER, JCLI_PASS)
print(jcli.run('group -a', f'gid {GROUP}', 'ok', label='group'))
print(jcli.run(
'user -a', f'uid {ESME_UID}', f'gid {GROUP}', f'username {ESME_UID}', f'password {ESME_PASSWORD}', 'ok',
label='user esme1',
))
print(jcli.run(
'user -a', f'uid {ESME2_UID}', f'gid {GROUP}', f'username {ESME2_UID}', f'password {ESME2_PASSWORD}', 'ok',
label='user esme2',
))
print(jcli.run(
f'user -u {ESME2_UID}', f'mt_messaging_cred quota smpps_throughput {ESME2_THROUGHPUT}', 'ok',
label='user esme2 throughput quota',
))
print(jcli.run(
'smppccm -a',
f'cid {CONNECTOR_CID}',
f'host {CONNECTOR_HOST}',
f'port {CONNECTOR_PORT}',
f'username {CONNECTOR_USERNAME}',
f'password {CONNECTOR_PASSWORD}',
'bind transceiver',
'con_fail_delay 2',
'con_loss_delay 2',
# The connector's own default (1) is 1 msg/s - a second submit while the first is still
# in flight (a message's 2nd segment, or a 2nd message sent right after) then never reaches
# the connector's peer at all inside any sane test budget; see findings/03-jasmin.md.
'submit_throughput 0',
'ok',
label='smppccm',
))
started = jcli.send(f'smppccm -1 {CONNECTOR_CID}')
print(started)
if 'Successfully started' not in started:
raise RuntimeError(f'smppccm -1 {CONNECTOR_CID} failed: {started!r}')
print(jcli.run(
'mtrouter -a', 'type DefaultRoute', 'order 0', f'connector smppc({CONNECTOR_CID})', 'rate 0.0', 'ok',
label='mtrouter',
))
print(jcli.run(
'morouter -a', 'type DefaultRoute', 'order 0', f'connector smpps({ESME_UID})', 'ok',
label='morouter',
))
jcli.send('persist')
jcli.send('quit', wait=0.2)
print('jasmin bootstrap complete')
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -1,654 +0,0 @@
#
# This is the main Jasmin SMS gateway configuration file.
# For any modifications to this file, refer to Jasmin Documentation.
# If that does not help, post your question on Jasmin's web forum
# hosted at Google Groups: https://groups.google.com/group/jasmin-sms-gateway
#
# Do NOT simply read the instructions in here without understanding
# what they do. They're here only as hints or reminders. If you are unsure
# consult the online docs.
[smpp-server]
# SMPP Server identifier
#id = "smpps_01"
# If you want you can bind a single interface, you can specify its IP here
#bind = 0.0.0.0
# Accept connections on the specified port, default is 2775
#port = 2775
# Activate billing feature
# May be disabled if not needed/used
#billing_feature = True
# Timeout for response to bind request
#sessionInitTimerSecs = 30
# Enquire link interval
#enquireLinkTimerSecs = 30
# Maximum time lapse allowed between transactions, after which,
# the connection is considered as inactive
#inactivityTimerSecs = 300
# Timeout for responses to any request PDU
#responseTimerSecs = 60
# Timeout for reading a single PDU, this is the maximum lapse of time between
# receiving PDU's header and its complete read, if the PDU reading timed out,
# the connection is considered as 'corrupt' and will reconnect
#pduReadTimerSecs = 10
# When message is routed to a SMPP Client connecter: How much time it is kept in
# redis waiting for receipt
#dlr_expiry = 86400
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/default-smpps_01.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = midnight
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
#log_privacy = False
[smpp-server-pb]
# If you want you can bind a single interface, you can specify its IP here
#bind = 0.0.0.0
# Accept connections on the specified port, default is 14000
#port = 14000
# If authentication is True, access will require entering a username and password
# as defined in admin_username and admin_password, you can disable this security
# layer by setting authentication to False, in this case admin_* values are ignored.
#authentication = True
#admin_username = smppsadmin
# This is a MD5 password digest hex encoded
#admin_password = e97ab122faa16beea8682d84f3d2eea4
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/smpp-server-pb.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
[client-management]
# Jasmin persists its configuration profiles in /etc/jasmin/store by
# default. You can specify a custom location here
#store_path = /etc/jasmin/store
# If you want you can bind a single interface, you can specify its IP here
#bind = 0.0.0.0
# Accept connections on the specified port, default is 8989
#port = 8989
# If authentication is True, access will require entering a username and password
# as defined in admin_username and admin_password, you can disable this security
# layer by setting authentication to False, in this case admin_* values are ignored.
#authentication = True
#admin_username = cmadmin
# This is a MD5 password digest hex encoded
#admin_password = e1c5136acafb7016bc965597c992eb82
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/smppclient-manager.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
# The protocol version used to pickle objects before transfering
# them to client side, this is used in the client manager only,
# the pickle protocol defined in SMPPClientManagerPBProxy is set
# to 2 and is not configurable
#pickle_protocol = 2
[service-smppclient]
# For each smppclient connector a service is associated
# refer to "Message flows" documentation for more details
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/service-smppclients.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
[sm-listener]
# SM listener consumes submit_sm and deliver_sm messages from amqp broker
# refer to "Message flows" documentation for more details
# If publish_submit_sm_resp is True, any received SubmitSm PDU will be published
# to the 'messaging' exchange on 'submit.sm.resp.CID' route, useful when you have
# a third party application waiting for these messages.
#publish_submit_sm_resp = False
# If the error is defined in submit_error_retrial, Jasmin will retry sending submit_sm if it
# gets one of these errors.
# submit_sm retrial will be executed 'count' times and delayed for 'delay' seconds each time.
#submit_error_retrial = {
# 'ESME_RSYSERR': {'count': 2, 'delay': 30},
# 'ESME_RTHROTTLED': {'count': 20, 'delay': 30},
# 'ESME_RMSGQFUL': {'count': 2, 'delay': 180},
# 'ESME_RINVSCHED': {'count': 2, 'delay': 300},
# }
# The maximum number of seconds a message can stay in queue waiting for SMPPC to get ready for
# delivey (connected and bound).
#submit_max_age_smppc_not_ready = 1200
# Delay (seconds) when retrying a submit with a not-yet ready SMPPc
# Hint: for large scale messaging deployment, it is advised to set this value to few seconds
# in order to keep Jasmin free.
#submit_retrial_delay_smppc_not_ready = 30
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/messages.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = midnight
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
#log_privacy = False
[dlr]
# DLRLookup process id
#pid = main
# DLRLookup mechanism configuration
#dlr_lookup_retry_delay = 10
#dlr_lookup_max_retries = 2
# If smpp_receipt_on_success_submit_sm_resp is True, every connected user to smpp server will
# receive a receipt (data_sm or deliver_sm) whenever a submit_sm_resp is received
# for a message he sent and requested receipt for it.
#smpp_receipt_on_success_submit_sm_resp = False
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/messages.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = midnight
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
#log_privacy = False
[amqp-broker]
host=rabbitmq
port=5672
# The following directives define the way how Jasmin is connecting to the AMQP Broker,
# default values must work with a freshly installed RabbitMQ server.
#host = 127.0.0.1
#vhost = /
#spec = /etc/jasmin/resource/amqp0-9-1.xml
#port = 5672
#username = guest
#password = guest
#heartbeat = 0
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/amqp-client.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
#connection_loss_retry = True
#connection_failure_retry = True
#connection_loss_retry_delay = 10
#connection_loss_failure_delay = 10
[http-api]
# If you want you can bind a single interface, you can specify its IP here
#bind = 0.0.0.0
# Accept connections on the specified port, default is 1401
#port = 1401
# Activate billing feature
# May be disabled if not needed/used
#billing_feature = True
# How many message parts you can get for a long message, default is 5 so you
# can't exceed 800 characters (160x5) when sending a long latin message.
#long_content_max_parts = 5
# Splitting long content can be made through SAR options or UDH
# Possible values are: sar and udh
#long_content_split = udh
# Specify the access log file path
#access_log = /var/log/jasmin/http-access.log
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/http-api.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
#log_privacy = False
[router]
# Jasmin router persists its routing configuration profiles in /etc/jasmin/store by
# default. You can specify a custom location here
#store_path = /etc/jasmin/store
# Router will automatically persist users and groups to disk whenever a critical information
# is updated (ex: user balance), persistence is executed every persistence_timer_secs
#persistence_timer_secs = 60
# If you want you can bind a single interface, you can specify its IP here
#bind = 0.0.0.0
# Accept connections on the specified port, default is 8988
#port = 8988
# If authentication is True, access will require entering a username and password
# as defined in admin_username and admin_password, you can disable this security
# layer by setting authentication to False, in this case admin_* values are ignored.
#authentication = True
#admin_username = radmin
# This is a MD5 password digest hex encoded
#admin_password = 82a606ca5a0deea2b5777756788af5c8
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/router.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
# The protocol version used to pickle objects before transfering
# them to client side, this is used in the client manager only,
# the pickle protocol defined in SMPPClientManagerPBProxy is set
# to 2 and is not configurable
#pickle_protocol = 2
[deliversm-thrower]
# The following directives define the process of delivery SMS-MO through http to third party
# application, it is explained in "HTTP API" documentation
# Sets socket timeout in seconds for outgoing client http connections.
#http_timeout = 30
# Define how many seconds should pass within the queuing system for retrying a failed throw.
#retry_delay = 30
# Define how many retries should be performed for failing throws of SMS-MO.
#max_retries = 3
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/deliversm-thrower.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
[dlr-thrower]
# The following directives define the process of delivering delivery-receipts through http to third party
# application, it is explained in "HTTP API" documentation
# Sets socket timeout in seconds for outgoing client http connections.
#http_timeout = 30
# Define how many seconds should pass within the queuing system for retrying a failed throw.
#retry_delay = 30
# Define how many retries should be performed for failing throws of DLR.
#max_retries = 3
# Specify the pdu type to consider when throwing a receipt through SMPPs, possible values:
# - data_sm
# - deliver_sm (default pdu)
dlr_pdu = data_sm
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/dlr-thrower.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
[redis-client]
host=redis
port=6379
# The following directives define the way how Jasmin is connecting to the redis server,
# default values must work with a freshly installed redis server.
#host = 127.0.0.1
#port = 6379
#dbid = 0
#password = None
#poolsize = 10
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/redis-client.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
[jcli]
bind=0.0.0.0
# If you want you can bind a single interface, you can specify its IP here
#bind = 127.0.0.1
# Accept connections on the specified port, default is 8990
#port = 8990
# If authentication is True, access will require entering a username and password
# as defined in admin_username and admin_password, you can disable this security
# layer by setting authentication to False, in this case admin_* values are ignored.
#authentication = True
#admin_username = jcliadmin
# This is a MD5 password digest hex encoded
#admin_password = 79e9b0aa3f3e7c53e916f7ac47439bcb
# Specify the server verbosity level.
# This can be one of:
# NOTSET (disable logging)
# DEBUG (a lot of information, useful for development/testing)
# INFO (moderately verbose, what you want in production probably)
# WARNING (only very important / critical messages and errors are logged)
# ERROR (only errors / critical messages are logged)
# CRITICAL (only critical messages are logged)
#log_level = INFO
# Specify the log file path
#log_file = /var/log/jasmin/jcli.log
# When to rotate the log file, possible values:
# S: Seconds
# M: Minutes
# H: Hours
# D: Days
# W0-W6: Weekday (0=Monday)
# midnight: Roll over at midnight
#log_rotate = W6
# The following directives define logging patterns including:
# - log_format: using python logging's attributes
# refer to https://docs.python.org/2/library/logging.html#logrecord-attributes
# -log_date_format: using python strftime formating directives
# refer to https://docs.python.org/2/library/time.html#time.strftime
#log_format = %(asctime)s %(levelname)-8s %(process)d %(message)s
#log_date_format = %Y-%m-%d %H:%M:%S
[interceptor-client]
# The following directives define client connector to InterceptorPB, it's used when jasmind
# is started with --enable-interceptor-client
#host = 127.0.0.1
#port = 8987
#username = iadmin
#password = ipwd
-32
View File
@@ -1,32 +0,0 @@
# jsmpp has no published artifact for this pin; cloned at a fixed commit and installed into the
# local Maven repo, which the driver build below then depends on.
FROM maven:3.9.11-eclipse-temurin-21 AS jsmpp-source
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
ARG JSMPP_COMMIT=a24db96ad7014cdc84a3eebfa64a75c362daef2a
RUN git clone https://github.com/opentelecoms-org/jsmpp.git /src \
&& cd /src \
&& git checkout "${JSMPP_COMMIT}" \
&& mvn -q -pl jsmpp -am install -DskipTests -Dgpg.skip=true
FROM maven:3.9.11-eclipse-temurin-21 AS build
COPY --from=jsmpp-source /root/.m2 /root/.m2
WORKDIR /driver
COPY pom.xml .
COPY src ./src
RUN mvn -q package -DskipTests
FROM eclipse-temurin:21.0.8_9-jre-jammy AS runtime
WORKDIR /app
COPY --from=build /driver/target/driver.jar ./driver.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "driver.jar"]
CMD ["node", "2775"]
-47
View File
@@ -1,47 +0,0 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>se.larvit.interop</groupId>
<artifactId>jsmpp-driver</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.jsmpp</groupId>
<artifactId>jsmpp</artifactId>
<version>3.0.3-SNAPSHOT</version>
</dependency>
</dependencies>
<build>
<finalName>driver</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>smpp.interop.jsmpp.Driver</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,540 +0,0 @@
package smpp.interop.jsmpp;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.jsmpp.InvalidResponseException;
import org.jsmpp.PDUException;
import org.jsmpp.bean.Alphabet;
import org.jsmpp.bean.AlertNotification;
import org.jsmpp.bean.BindType;
import org.jsmpp.bean.DataSm;
import org.jsmpp.bean.DeliverSm;
import org.jsmpp.bean.ESMClass;
import org.jsmpp.bean.GeneralDataCoding;
import org.jsmpp.bean.NumberingPlanIndicator;
import org.jsmpp.bean.OptionalParameter;
import org.jsmpp.bean.RegisteredDelivery;
import org.jsmpp.bean.SMSCDeliveryReceipt;
import org.jsmpp.bean.TypeOfNumber;
import org.jsmpp.bean.InterfaceVersion;
import org.jsmpp.extra.NegativeResponseException;
import org.jsmpp.extra.ProcessRequestException;
import org.jsmpp.extra.ResponseTimeoutException;
import org.jsmpp.session.BindParameter;
import org.jsmpp.session.MessageReceiverListener;
import org.jsmpp.session.QuerySmResult;
import org.jsmpp.session.SMPPSession;
import org.jsmpp.session.Session;
import org.jsmpp.session.SubmitSmResult;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* An HTTP-driven jsmpp ESME: each request binds (if needed), performs one scenario action against
* the SMPP server named by host/port args, and answers with the result as JSON. Kept alive as one
* process so a session can be reused across several requests, the way a real ESME would.
*/
public final class Driver {
private static final Map<String, SMPPSession> sessions = new ConcurrentHashMap<>();
private static final Map<String, Integer> requestedVersions = new ConcurrentHashMap<>();
private static final Map<String, Socket> rawSockets = new ConcurrentHashMap<>();
private static final Map<String, Integer> rawSeqNr = new ConcurrentHashMap<>();
private static String host;
private static int port;
private Driver() { }
public static void main(String[] args) throws IOException {
host = args.length > 0 ? args[0] : "node";
port = args.length > 1 ? Integer.parseInt(args[1]) : 2775;
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/health", exchange -> respond(exchange, 200, "{\"ok\":true}"));
server.createContext("/bind", Driver::handleBind);
server.createContext("/unbind", Driver::handleUnbind);
server.createContext("/enquireLink", Driver::handleEnquireLink);
server.createContext("/submit", Driver::handleSubmit);
server.createContext("/load", Driver::handleLoad);
server.createContext("/querySm", exchange -> handleUnhandledCommand(exchange, "query"));
server.createContext("/cancelSm", exchange -> handleUnhandledCommand(exchange, "cancel"));
server.createContext("/replaceSm", exchange -> handleUnhandledCommand(exchange, "replace"));
server.createContext("/rawBind", Driver::handleRawBind);
server.createContext("/rawUnknownCommand", exchange -> handleRawAction(exchange, RawSmpp::unknownCommandPdu));
server.createContext("/rawTruncatedTlv", exchange -> handleRawAction(exchange, RawSmpp::truncatedTlvDeliverSmPdu));
server.createContext("/rawShortBody", exchange -> handleRawAction(exchange, RawSmpp::shortBodyDeliverSmPdu));
server.createContext("/rawEnquireLink", exchange -> handleRawAction(exchange, RawSmpp::enquireLinkPdu));
server.setExecutor(null);
server.start();
System.out.println("jsmpp driver listening on 8080, target " + host + ":" + port);
}
// --- HTTP plumbing ---
private static Map<String, String> queryParams(HttpExchange exchange) {
Map<String, String> params = new LinkedHashMap<>();
String query = exchange.getRequestURI().getRawQuery();
if (query == null) return params;
for (String pair : query.split("&")) {
int eq = pair.indexOf('=');
String key = eq < 0 ? pair : pair.substring(0, eq);
String value = eq < 0 ? "" : URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
params.put(key, value);
}
return params;
}
private static void respond(HttpExchange exchange, int status, String body) {
try {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(bytes);
}
} catch (IOException e) {
// The client gave up reading; nothing left to answer.
}
}
private static void respondOk(HttpExchange exchange, Map<String, Object> result) {
respond(exchange, 200, Json.write(result));
}
private static void respondErr(HttpExchange exchange, Exception e) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", false);
result.put("errorClass", e.getClass().getName());
result.put("error", String.valueOf(e.getMessage()));
if (e instanceof NegativeResponseException nre) {
result.put("commandStatus", nre.getCommandStatus());
result.put("commandStatusHex", "0x" + Integer.toHexString(nre.getCommandStatus()));
}
respond(exchange, 200, Json.write(result));
}
// --- jsmpp-backed handlers ---
private static void handleBind(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
String name = p.getOrDefault("session", "default");
try {
SMPPSession session = new SMPPSession();
session.setMessageReceiverListener(new NoopListener());
String type = p.getOrDefault("type", "transceiver");
BindType bindType = switch (type) {
case "receiver" -> BindType.BIND_RX;
case "transmitter" -> BindType.BIND_TX;
default -> BindType.BIND_TRX;
};
int ifVersion = Integer.parseInt(p.getOrDefault("interfaceVersion", "52"));
InterfaceVersion interfaceVersion = InterfaceVersion.valueOf((byte) ifVersion);
String systemId = p.getOrDefault("systemId", "jsmpp");
String password = p.getOrDefault("password", "jsmpppw");
BindParameter bindParameter = new BindParameter(bindType, systemId, password, "interop",
TypeOfNumber.UNKNOWN, NumberingPlanIndicator.UNKNOWN, null, interfaceVersion);
String scSystemId = session.connectAndBind(host, port, bindParameter);
sessions.put(name, session);
requestedVersions.put(name, ifVersion);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("scSystemId", scSystemId);
result.put("requestedInterfaceVersion", ifVersion);
result.put("negotiatedInterfaceVersion", session.getInterfaceVersion().value() & 0xFF);
respondOk(exchange, result);
} catch (Exception e) {
respondErr(exchange, e);
}
}
private static void handleUnbind(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SMPPSession session = sessions.remove(p.getOrDefault("session", "default"));
try {
if (session != null) session.unbindAndClose();
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
respondOk(exchange, result);
} catch (Exception e) {
respondErr(exchange, e);
}
}
private static void handleEnquireLink(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
Map<String, Object> result = new LinkedHashMap<>();
if (session == null) {
result.put("ok", false);
result.put("error", "no such session");
respondOk(exchange, result);
return;
}
result.put("ok", true);
result.put("sessionState", session.getSessionState().name());
respondOk(exchange, result);
}
private static byte[] encode(String text, String encoding) {
return switch (encoding) {
case "ucs2" -> text.getBytes(StandardCharsets.UTF_16BE);
case "latin1" -> text.getBytes(StandardCharsets.ISO_8859_1);
default -> text.getBytes(StandardCharsets.US_ASCII);
};
}
private static GeneralDataCoding dataCoding(String encoding) {
Alphabet alphabet = switch (encoding) {
case "ucs2" -> Alphabet.ALPHA_UCS2;
case "latin1" -> Alphabet.ALPHA_LATIN1;
default -> Alphabet.ALPHA_DEFAULT;
};
return new GeneralDataCoding(alphabet, null, false);
}
/** Chunk size chosen well under any segment limit for every encoding this driver sends. */
private static final int CHUNK_CHARS = 130;
private static void handleSubmit(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
if (session == null) {
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
return;
}
String from = p.getOrDefault("from", "12345");
String to = p.getOrDefault("to", "67890");
String text = p.getOrDefault("text", "hello");
String mode = p.getOrDefault("mode", "plain");
String encoding = p.getOrDefault("encoding", "gsm7");
try {
java.util.List<Map<String, Object>> segments = new java.util.ArrayList<>();
switch (mode) {
case "udh8" -> submitUdh(session, from, to, text, encoding, false, segments);
case "udh16" -> submitUdh(session, from, to, text, encoding, true, segments);
case "sar" -> submitSar(session, from, to, text, encoding, segments);
case "payload" -> submitPayload(session, from, to, text, encoding, segments);
default -> submitPlain(session, from, to, text, encoding, segments);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("segments", segments);
respondOk(exchange, result);
} catch (NegativeResponseException e) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("refused", true);
result.put("commandStatus", e.getCommandStatus());
result.put("commandStatusHex", "0x" + Integer.toHexString(e.getCommandStatus()));
respondOk(exchange, result);
} catch (Exception e) {
respondErr(exchange, e);
}
}
/**
* Pushes count messages over an already-bound session and reports the rate. jsmpp's submit is
* blocking, so threads are what put requests in flight here — the window is the pool size.
*/
private static void handleLoad(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
if (session == null) {
Map<String, Object> missing = new LinkedHashMap<>();
missing.put("ok", false);
missing.put("error", "no such session");
respondOk(exchange, missing);
return;
}
int count = Integer.parseInt(p.getOrDefault("count", "20000"));
int concurrency = Integer.parseInt(p.getOrDefault("concurrency", "50"));
String from = p.getOrDefault("from", "BENCH");
String to = p.getOrDefault("to", "46709771337");
String text = p.getOrDefault("text", "benchmark");
AtomicInteger issued = new AtomicInteger();
AtomicInteger failed = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(concurrency);
long started = System.nanoTime();
for (int worker = 0; worker < concurrency; worker++) {
pool.execute(() -> {
while (issued.getAndIncrement() < count) {
try {
session.submitShortMessage("CMT",
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
new ESMClass(), (byte) 0, (byte) 1, null, null,
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0,
dataCoding("ascii"), (byte) 0, encode(text, "ascii"));
} catch (Exception e) {
failed.incrementAndGet();
}
}
});
}
pool.shutdown();
try {
if (!pool.awaitTermination(10, TimeUnit.MINUTES)) pool.shutdownNow();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
double seconds = (System.nanoTime() - started) / 1e9;
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("count", count);
result.put("concurrency", concurrency);
result.put("failed", failed.get());
result.put("seconds", Math.round(seconds * 1000d) / 1000d);
result.put("perSecond", Math.round(count / seconds));
respondOk(exchange, result);
}
private static void submitPlain(SMPPSession session, String from, String to, String text, String encoding,
java.util.List<Map<String, Object>> segments) throws Exception {
SubmitSmResult r = session.submitShortMessage("CMT",
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
new ESMClass(), (byte) 0, (byte) 1, null, null,
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
encode(text, encoding));
segments.add(Map.of("messageId", r.getMessageId()));
}
private static java.util.List<String> chunk(String text, int size) {
java.util.List<String> out = new java.util.ArrayList<>();
for (int i = 0; i < text.length(); i += size) {
out.add(text.substring(i, Math.min(text.length(), i + size)));
}
return out;
}
private static void submitUdh(SMPPSession session, String from, String to, String text, String encoding,
boolean sixteenBit, java.util.List<Map<String, Object>> segments) throws Exception {
java.util.List<String> chunks = chunk(text, CHUNK_CHARS);
int reference = sixteenBit ? 0x1234 : 0x42;
int total = chunks.size();
for (int i = 0; i < chunks.size(); i++) {
byte[] chunkBytes = encode(chunks.get(i), encoding);
byte[] udh = sixteenBit
? new byte[] { 0x06, 0x08, 0x04, (byte) ((reference >> 8) & 0xFF), (byte) (reference & 0xFF), (byte) total, (byte) (i + 1) }
: new byte[] { 0x05, 0x00, 0x03, (byte) reference, (byte) total, (byte) (i + 1) };
byte[] shortMessage = new byte[udh.length + chunkBytes.length];
System.arraycopy(udh, 0, shortMessage, 0, udh.length);
System.arraycopy(chunkBytes, 0, shortMessage, udh.length, chunkBytes.length);
SubmitSmResult r = session.submitShortMessage("CMT",
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
new ESMClass(0x40), (byte) 0, (byte) 1, null, null,
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
shortMessage);
segments.add(Map.of("messageId", r.getMessageId(), "part", i + 1, "total", total));
}
}
private static void submitSar(SMPPSession session, String from, String to, String text, String encoding,
java.util.List<Map<String, Object>> segments) throws Exception {
java.util.List<String> chunks = chunk(text, CHUNK_CHARS);
int reference = 0x77;
int total = chunks.size();
for (int i = 0; i < chunks.size(); i++) {
byte[] chunkBytes = encode(chunks.get(i), encoding);
OptionalParameter refNum = new OptionalParameter.Sar_msg_ref_num((short) reference);
OptionalParameter totalSegments = new OptionalParameter.Sar_total_segments((byte) total);
OptionalParameter seqNum = new OptionalParameter.Sar_segment_seqnum((byte) (i + 1));
SubmitSmResult r = session.submitShortMessage("CMT",
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
new ESMClass(), (byte) 0, (byte) 1, null, null,
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
chunkBytes, refNum, totalSegments, seqNum);
segments.add(Map.of("messageId", r.getMessageId(), "part", i + 1, "total", total));
}
}
private static void submitPayload(SMPPSession session, String from, String to, String text, String encoding,
java.util.List<Map<String, Object>> segments) throws Exception {
byte[] payload = encode(text, encoding);
OptionalParameter messagePayload = new OptionalParameter.Message_payload(payload);
SubmitSmResult r = session.submitShortMessage("CMT",
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, from,
TypeOfNumber.INTERNATIONAL, NumberingPlanIndicator.UNKNOWN, to,
new ESMClass(), (byte) 0, (byte) 1, null, null,
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, dataCoding(encoding), (byte) 0,
new byte[0], messagePayload);
segments.add(Map.of("messageId", r.getMessageId()));
}
private static void handleUnhandledCommand(HttpExchange exchange, String which) {
Map<String, String> p = queryParams(exchange);
SMPPSession session = sessions.get(p.getOrDefault("session", "default"));
String messageId = p.getOrDefault("messageId", "0");
if (session == null) {
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such session")));
return;
}
try {
switch (which) {
case "query" -> {
QuerySmResult r = session.queryShortMessage(messageId, TypeOfNumber.INTERNATIONAL,
NumberingPlanIndicator.UNKNOWN, "12345");
respondOk(exchange, Map.of("ok", true, "refused", false, "finalDate", String.valueOf(r.getFinalDate())));
}
case "cancel" -> {
session.cancelShortMessage("CMT", messageId, TypeOfNumber.INTERNATIONAL,
NumberingPlanIndicator.UNKNOWN, "12345", TypeOfNumber.INTERNATIONAL,
NumberingPlanIndicator.UNKNOWN, "67890");
respondOk(exchange, Map.of("ok", true, "refused", false));
}
case "replace" -> {
session.replaceShortMessage(messageId, TypeOfNumber.INTERNATIONAL,
NumberingPlanIndicator.UNKNOWN, "12345", null, null,
new RegisteredDelivery(SMSCDeliveryReceipt.DEFAULT), (byte) 0, "replacement".getBytes(StandardCharsets.US_ASCII));
respondOk(exchange, Map.of("ok", true, "refused", false));
}
default -> respond(exchange, 400, "{}");
}
} catch (NegativeResponseException e) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("refused", true);
result.put("commandStatus", e.getCommandStatus());
result.put("commandStatusHex", "0x" + Integer.toHexString(e.getCommandStatus()));
respondOk(exchange, result);
} catch (Exception e) {
respondErr(exchange, e);
}
}
// --- raw-socket handlers, for PDUs jsmpp's typed API cannot construct ---
private static void handleRawBind(HttpExchange exchange) {
Map<String, String> p = queryParams(exchange);
String name = p.getOrDefault("raw", "default");
try {
Socket sock = new Socket();
sock.connect(new InetSocketAddress(host, port), 5000);
int ifVersion = Integer.parseInt(p.getOrDefault("interfaceVersion", "52"));
int seqNr = 1;
RawSmpp.write(sock, RawSmpp.bindTransceiverPdu(
p.getOrDefault("systemId", "rawjsmpp"), p.getOrDefault("password", "rawpw"), ifVersion, seqNr));
Map<String, Object> resp = RawSmpp.readOne(sock, 5000);
rawSockets.put(name, sock);
rawSeqNr.put(name, seqNr + 1);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("bindResp", resp);
respondOk(exchange, result);
} catch (Exception e) {
respondErr(exchange, e);
}
}
private interface PduBuilder {
byte[] build(int seqNr);
}
private static void handleRawAction(HttpExchange exchange, PduBuilder builder) {
Map<String, String> p = queryParams(exchange);
String name = p.getOrDefault("raw", "default");
Socket sock = rawSockets.get(name);
if (sock == null) {
respond(exchange, 200, Json.write(Map.of("ok", false, "error", "no such raw socket - call rawBind first")));
return;
}
try {
int seqNr = rawSeqNr.getOrDefault(name, 1);
RawSmpp.write(sock, builder.build(seqNr));
rawSeqNr.put(name, seqNr + 1);
Map<String, Object> resp = RawSmpp.readOne(sock, 5000);
Map<String, Object> result = new LinkedHashMap<>();
result.put("ok", true);
result.put("response", resp);
respondOk(exchange, result);
} catch (Exception e) {
respondErr(exchange, e);
}
}
private static final class NoopListener implements MessageReceiverListener {
@Override
public void onAcceptDeliverSm(DeliverSm deliverSm) throws ProcessRequestException {
// This phase's scenarios never have the SMSC push a deliver_sm to jsmpp.
}
@Override
public void onAcceptAlertNotification(AlertNotification alertNotification) {
// Nothing to do: no scenario here sends one.
}
@Override
public org.jsmpp.session.DataSmResult onAcceptDataSm(DataSm dataSm, Session source)
throws ProcessRequestException {
throw new ProcessRequestException("data_sm not supported by this driver", 3);
}
}
}
@@ -1,75 +0,0 @@
package smpp.interop.jsmpp;
import java.util.List;
import java.util.Map;
/** A minimal JSON writer for the driver's own controlled output - no parsing needed. */
final class Json {
private Json() { }
static String write(Object value) {
StringBuilder sb = new StringBuilder();
writeValue(sb, value);
return sb.toString();
}
@SuppressWarnings("unchecked")
private static void writeValue(StringBuilder sb, Object value) {
if (value == null) {
sb.append("null");
} else if (value instanceof String s) {
writeString(sb, s);
} else if (value instanceof Boolean || value instanceof Integer || value instanceof Long) {
sb.append(value);
} else if (value instanceof Map<?, ?> map) {
sb.append('{');
boolean first = true;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!first) sb.append(',');
first = false;
writeString(sb, String.valueOf(entry.getKey()));
sb.append(':');
writeValue(sb, entry.getValue());
}
sb.append('}');
} else if (value instanceof List<?> list) {
sb.append('[');
boolean first = true;
for (Object item : list) {
if (!first) sb.append(',');
first = false;
writeValue(sb, item);
}
sb.append(']');
} else if (value instanceof byte[] bytes) {
writeString(sb, hex(bytes));
} else {
writeString(sb, String.valueOf(value));
}
}
private static void writeString(StringBuilder sb, String s) {
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
}
sb.append('"');
}
static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
}
@@ -1,176 +0,0 @@
package smpp.interop.jsmpp;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* A hand-rolled SMPP encoder over a plain socket, for the malformed PDUs jsmpp's own typed API
* cannot construct (target 1: unknown command id, a truncated TLV stream, a body shorter than it
* declares). Only what these scenarios need - a bind and the malformed shapes - not a real client.
*/
final class RawSmpp {
private RawSmpp() { }
private static void u8(ByteArrayOutputStream out, int v) {
out.write(v & 0xFF);
}
private static void u32(ByteArrayOutputStream out, long v) {
out.write((int) ((v >> 24) & 0xFF));
out.write((int) ((v >> 16) & 0xFF));
out.write((int) ((v >> 8) & 0xFF));
out.write((int) (v & 0xFF));
}
private static void cstring(ByteArrayOutputStream out, String s) {
if (s != null && !s.isEmpty()) out.writeBytes(s.getBytes(StandardCharsets.ISO_8859_1));
out.write(0);
}
private static byte[] pdu(int cmdId, int status, int seqNr, byte[] body) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
u32(out, 16L + body.length);
u32(out, cmdId);
u32(out, status);
u32(out, seqNr);
out.writeBytes(body);
return out.toByteArray();
}
static byte[] bindTransceiverPdu(String systemId, String password, int interfaceVersion, int seqNr) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
cstring(body, systemId);
cstring(body, password);
cstring(body, "");
u8(body, interfaceVersion);
u8(body, 0);
u8(body, 0);
cstring(body, "");
return pdu(0x00000009, 0, seqNr, body.toByteArray());
}
/** command_id 0x00050001 names no SMPP 3.4 command - an empty body is a well-framed PDU. */
static byte[] unknownCommandPdu(int seqNr) {
return pdu(0x00050001, 0, seqNr, new byte[0]);
}
/**
* A deliver_sm whose mandatory fields are all present and correct, followed by one TLV header
* whose declared length (200) runs past cmd_length - the codec's parseTlvs() refuses this with
* reason 'tlvs'.
*/
static byte[] truncatedTlvDeliverSmPdu(int seqNr) {
ByteArrayOutputStream body = deliverSmMandatory("raw-from", "raw-to", "truncated tlv probe");
// Tag 0x001D (any tag id serves), declared length 200, only 4 value octets actually follow.
// A full 8-byte tail (not a bare 4-byte header) so the codec's trailing-NUL retry - which
// shifts the TLV region by one octet looking for a padded short_message - still finds an
// overrunning length rather than silently swallowing an unparsed remainder as alignment slack.
body.write(0x00);
body.write(0x1D);
body.write(0x00);
body.write(0xC8);
body.write(0x41);
body.write(0x41);
body.write(0x41);
body.write(0x41);
return pdu(0x00000005, 0, seqNr, body.toByteArray());
}
/** A deliver_sm whose sm_length declares 200 octets while only 5 are actually present. */
static byte[] shortBodyDeliverSmPdu(int seqNr) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
cstring(body, "");
u8(body, 0);
u8(body, 0);
cstring(body, "raw-from");
u8(body, 0);
u8(body, 0);
cstring(body, "raw-to");
u8(body, 0);
u8(body, 0);
u8(body, 0);
cstring(body, "");
cstring(body, "");
u8(body, 0);
u8(body, 0);
u8(body, 0);
u8(body, 0);
u8(body, 200); // sm_length declares 200 octets
body.writeBytes("short".getBytes(StandardCharsets.ISO_8859_1)); // only 5 actually follow
return pdu(0x00000005, 0, seqNr, body.toByteArray());
}
static byte[] enquireLinkPdu(int seqNr) {
return pdu(0x00000015, 0, seqNr, new byte[0]);
}
private static ByteArrayOutputStream deliverSmMandatory(String from, String to, String text) {
ByteArrayOutputStream body = new ByteArrayOutputStream();
cstring(body, "");
u8(body, 0);
u8(body, 0);
cstring(body, from);
u8(body, 0);
u8(body, 0);
cstring(body, to);
u8(body, 0);
u8(body, 0);
u8(body, 0);
cstring(body, "");
cstring(body, "");
u8(body, 0);
u8(body, 0);
u8(body, 0);
u8(body, 0);
byte[] textBytes = text.getBytes(StandardCharsets.ISO_8859_1);
u8(body, textBytes.length);
body.writeBytes(textBytes);
return body;
}
static void write(Socket sock, byte[] pdu) throws IOException {
OutputStream out = sock.getOutputStream();
out.write(pdu);
out.flush();
}
/** Reads exactly one PDU (command_length-framed) and parses its 16-octet header. */
static Map<String, Object> readOne(Socket sock, int timeoutMs) throws IOException {
sock.setSoTimeout(timeoutMs);
DataInputStream in = new DataInputStream(sock.getInputStream());
byte[] lenBytes = new byte[4];
in.readFully(lenBytes);
long cmdLength = ((long) (lenBytes[0] & 0xFF) << 24) | ((lenBytes[1] & 0xFF) << 16)
| ((lenBytes[2] & 0xFF) << 8) | (lenBytes[3] & 0xFF);
byte[] rest = new byte[(int) cmdLength - 4];
in.readFully(rest);
int cmdId = b32(rest, 0);
int status = b32(rest, 4);
int seqNr = b32(rest, 8);
Map<String, Object> result = new LinkedHashMap<>();
result.put("cmdId", cmdId);
result.put("cmdIdHex", "0x" + Integer.toHexString(cmdId));
result.put("cmdStatus", status);
result.put("cmdStatusHex", "0x" + Integer.toHexString(status));
result.put("seqNr", seqNr);
byte[] full = new byte[4 + rest.length];
System.arraycopy(lenBytes, 0, full, 0, 4);
System.arraycopy(rest, 0, full, 4, rest.length);
result.put("hex", Json.hex(full));
return result;
}
private static int b32(byte[] b, int offset) {
return ((b[offset] & 0xFF) << 24) | ((b[offset + 1] & 0xFF) << 16)
| ((b[offset + 2] & 0xFF) << 8) | (b[offset + 3] & 0xFF);
}
}
-7
View File
@@ -1,7 +0,0 @@
FROM debian:bookworm-20260824-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends kannel=1.4.5-12 \
&& rm -rf /var/lib/apt/lists/*
COPY main.conf iv33.conf maxpending1.conf notransceiver.conf /etc/kannel/
-40
View File
@@ -1,40 +0,0 @@
group = core
admin-port = 13000
admin-password = kanneladmin
smsbox-port = 13001
log-level = 0
dlr-storage = internal
group = smsc
smsc = smpp
smsc-id = node-iv33
host = node
port = 2775
smsc-username = kannel-iv33
smsc-password = kannelpw
system-type = "kannel-esme"
transceiver-mode = true
interface-version = "33"
enquire-link-interval = 5
max-pending-submits = 10
reconnect-delay = 1
wait-ack = 5
group = smsbox
bearerbox-host = kannel-iv33-bearerbox
sendsms-port = 13013
global-sender = 46700000000
http-request-retry = 3
http-queue-delay = 1
group = sendsms-user
username = tester
password = testerpw
max-messages = 10
concatenation = true
group = sms-service
keyword = default
get-url = "http://node:8080/mo/iv33?from=%p&to=%P&text=%a&coding=%c&udh=%u"
max-messages = 0
omit-empty = true
-43
View File
@@ -1,43 +0,0 @@
group = core
admin-port = 13000
admin-password = kanneladmin
smsbox-port = 13001
log-level = 0
dlr-storage = internal
group = smsc
smsc = smpp
smsc-id = node-main
host = node
port = 2775
smsc-username = kannel
smsc-password = kannelpw
system-type = "kannel-esme"
transceiver-mode = true
interface-version = "34"
enquire-link-interval = 5
max-pending-submits = 10
reconnect-delay = 1
wait-ack = 5
wait-ack-expire = 0
group = smsbox
bearerbox-host = kannel-bearerbox
sendsms-port = 13013
global-sender = 46700000000
# Kannel's HTTP client intermittently loses the race on its own non-blocking connect
# ("Socket not connected", no retry by default) - retry so a flaky fetch isn't a lost DLR/MO.
http-request-retry = 3
http-queue-delay = 1
group = sendsms-user
username = tester
password = testerpw
max-messages = 10
concatenation = true
group = sms-service
keyword = default
get-url = "http://node:8080/mo?from=%p&to=%P&text=%a&coding=%c&udh=%u"
max-messages = 0
omit-empty = true
@@ -1,40 +0,0 @@
group = core
admin-port = 13000
admin-password = kanneladmin
smsbox-port = 13001
log-level = 0
dlr-storage = internal
group = smsc
smsc = smpp
smsc-id = node-maxp1
host = node
port = 2775
smsc-username = kannel-maxp1
smsc-password = kannelpw
system-type = "kannel-esme"
transceiver-mode = true
interface-version = "34"
enquire-link-interval = 5
max-pending-submits = 1
reconnect-delay = 1
wait-ack = 5
group = smsbox
bearerbox-host = kannel-maxp1-bearerbox
sendsms-port = 13013
global-sender = 46700000000
http-request-retry = 3
http-queue-delay = 1
group = sendsms-user
username = tester
password = testerpw
max-messages = 10
concatenation = true
group = sms-service
keyword = default
get-url = "http://node:8080/mo/maxp1?from=%p&to=%P&text=%a&coding=%c&udh=%u"
max-messages = 0
omit-empty = true
@@ -1,55 +0,0 @@
group = core
admin-port = 13000
admin-password = kanneladmin
smsbox-port = 13001
log-level = 0
dlr-storage = internal
# 1.4.5-12 panics on one group with both port and receive-port set ("deprecated"); a non-transceiver
# TX/RX pair against the same host needs two groups sharing an smsc-id instead.
group = smsc
smsc = smpp
smsc-id = node-notrx
host = node
port = 2775
smsc-username = kannel-notrx
smsc-password = kannelpw
system-type = "kannel-esme"
interface-version = "34"
enquire-link-interval = 5
max-pending-submits = 10
reconnect-delay = 1
wait-ack = 5
group = smsc
smsc = smpp
smsc-id = node-notrx
host = node
receive-port = 2775
smsc-username = kannel-notrx
smsc-password = kannelpw
system-type = "kannel-esme"
interface-version = "34"
enquire-link-interval = 5
max-pending-submits = 10
reconnect-delay = 1
wait-ack = 5
group = smsbox
bearerbox-host = kannel-notrx-bearerbox
sendsms-port = 13013
global-sender = 46700000000
http-request-retry = 3
http-queue-delay = 1
group = sendsms-user
username = tester
password = testerpw
max-messages = 10
concatenation = true
group = sms-service
keyword = default
get-url = "http://node:8080/mo/notrx?from=%p&to=%P&text=%a&coding=%c&udh=%u"
max-messages = 0
omit-empty = true
-35
View File
@@ -1,35 +0,0 @@
# No licence declared for this fork (composer.json says LGPL-2.0-or-later, but no top-level
# LICENSE file - see findings/06-python-php.md); test-only, cloned at a pinned commit, never
# vendored into this repo.
FROM php:8.4.25-cli AS source
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ARG PHPSMPP_COMMIT=1d3b53c2d2b63d51ab70009d956914b7f8903118
RUN git clone https://github.com/alexandr-mironov/php-smpp.git /src \
&& cd /src \
&& git checkout "${PHPSMPP_COMMIT}"
# PHP 8's sockets extension returns a Socket object from socket_create(), not a resource; this
# fork's own isOpen() checks is_resource() alone (written pre-PHP8), so it is always false and every
# guarded call ("Socket is not open") fails immediately, on any PHP 8.x. Patched here, not in
# src/Socket.php - see findings/06-python-php.md.
RUN sed -i 's/if (!is_resource(\$this->socket)) {/if (!is_resource(\$this->socket) \&\& !(\$this->socket instanceof \\Socket)) {/' /src/src/transport/Socket.php
FROM php:8.4.25-cli
RUN apt-get update \
&& apt-get install -y --no-install-recommends libonig-dev \
&& rm -rf /var/lib/apt/lists/* \
&& docker-php-ext-install sockets mbstring
WORKDIR /app
COPY --from=source /src/src /app/smpp-src
COPY driver.php .
EXPOSE 8080
ENTRYPOINT ["php", "/app/driver.php"]
CMD ["node", "2775"]
-340
View File
@@ -1,340 +0,0 @@
<?php
declare(strict_types=1);
// HTTP-driven php-smpp ESME: one connection at a time (php-smpp itself blocks synchronously on
// every send and read), holding named client sockets open across requests the way the Java and
// Python drivers do (see AGENTS.md). No composer: a tiny PSR-4-shaped autoloader over the fork's
// own src/ tree, cloned at a pinned commit during the image build.
spl_autoload_register(function (string $class): void {
$prefix = 'smpp\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$relative = substr($class, strlen($prefix));
$path = '/app/smpp-src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require $path;
}
});
use smpp\Address;
use smpp\Client;
use smpp\DeliveryReceipt;
use smpp\exceptions\SmppException;
use smpp\helpers\GsmEncoderHelper;
use smpp\SMPP;
use smpp\transport\Socket;
$targetHost = $argv[1] ?? 'node';
$targetPort = (int) ($argv[2] ?? 2775);
/** @var array<string, Client> */
$clients = [];
function jsonBody(): array
{
$raw = file_get_contents('php://input');
return $raw === '' || $raw === false ? [] : (json_decode($raw, true) ?? []);
}
function respond(int $status, array $payload): void
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($payload);
}
function encodeBody(string $text, int $dataCoding): string
{
if ($dataCoding === SMPP::DATA_CODING_DEFAULT) {
return GsmEncoderHelper::utf8_to_gsm0338($text);
}
if ($dataCoding === SMPP::DATA_CODING_ISO8859_1) {
return mb_convert_encoding($text, 'ISO-8859-1', 'UTF-8');
}
// UCS2: sendSMS() converts internally, so the raw UTF-8 text passes straight through here.
return $text;
}
// Inverse of GsmEncoderHelper's dict, for decoding what this driver receives - reusing the peer's
// own table so a mismatch against what was sent is the peer's own encode/decode disagreeing with
// itself, not this driver guessing at the real GSM 03.38 one.
function gsmDecode(string $data): string
{
static $reverse = null;
if ($reverse === null) {
$reverse = [];
foreach (gsmEncodeDict() as $char => $bytes) {
$reverse[$bytes] = $char;
}
}
$result = '';
$length = strlen($data);
$i = 0;
while ($i < $length) {
if ($data[$i] === "\x1B" && $i + 1 < $length) {
$pair = substr($data, $i, 2);
$result .= $reverse[$pair] ?? '?';
$i += 2;
} else {
$result .= $reverse[$data[$i]] ?? $data[$i];
$i += 1;
}
}
return $result;
}
// The dict inside utf8_to_gsm0338() is a local literal, not a class constant - re-derived once by
// encoding every basic-table/extension character singly and reading back what came out, rather
// than duplicating the private table here.
function gsmEncodeDict(): array
{
static $dict = null;
if ($dict !== null) {
return $dict;
}
$dict = [];
$candidates = ['@', '£', '$', '¥', 'è', 'é', 'ù', 'ì', 'ò', 'Ç', 'Ø', 'ø', 'Å', 'å', 'Δ', '_', 'Φ', 'Γ', 'Λ', 'Ω', 'Π', 'Ψ', 'Σ', 'Θ', 'Ξ', 'Æ', 'æ', 'ß', 'É', '¡', 'Ä', 'Ö', 'Ñ', 'Ü', '§', '¿', 'ä', 'ö', 'ñ', 'ü', 'à', '^', '{', '}', '\\', '[', '~', ']', '|', '€'];
foreach ($candidates as $char) {
$encoded = GsmEncoderHelper::utf8_to_gsm0338($char);
if ($encoded !== $char) {
$dict[$char] = $encoded;
}
}
return $dict;
}
function decodeBody(string $data, int $dataCoding): string
{
if ($dataCoding === SMPP::DATA_CODING_UCS2) {
return mb_convert_encoding($data, 'UTF-8', 'UCS-2BE');
}
if ($dataCoding === SMPP::DATA_CODING_ISO8859_1) {
return mb_convert_encoding($data, 'UTF-8', 'ISO-8859-1');
}
return gsmDecode($data);
}
function doBind(array $body): array
{
global $clients, $targetHost, $targetPort;
$name = $body['name'];
$recvTimeoutMs = (int) ($body['recvTimeoutMs'] ?? 5000);
$transport = new Socket([$targetHost], $targetPort);
$transport->setRecvTimeout($recvTimeoutMs);
$transport->open();
$client = new Client($transport);
Client::$smsNullTerminateOctetStrings = false;
if (isset($body['interfaceVersion'])) {
Client::$interfaceVersion = (int) $body['interfaceVersion'];
}
$systemId = $body['systemId'];
$password = $body['password'];
switch ($body['mode']) {
case 'transmitter':
$client->bindTransmitter($systemId, $password);
break;
case 'receiver':
$client->bindReceiver($systemId, $password);
break;
default:
$client->bindTransceiver($systemId, $password);
}
$clients[$name] = $client;
return ['ok' => true];
}
function doUnbind(array $body): array
{
global $clients;
$clients[$body['name']]->close();
unset($clients[$body['name']]);
return ['ok' => true];
}
function doEnquireLink(array $body): array
{
global $clients;
$clients[$body['name']]->enquireLink();
return ['ok' => true];
}
/** Single, non-CSMS submit - used by the refusal and bind-direction scenarios. */
function doSubmit(array $body): array
{
global $clients;
$client = $clients[$body['name']];
$dataCoding = (int) ($body['dataCoding'] ?? SMPP::DATA_CODING_DEFAULT);
$message = encodeBody($body['text'], $dataCoding);
$from = new Address($body['from']);
$to = new Address($body['to']);
try {
$messageId = $client->sendSMS($from, $to, $message, null, $dataCoding);
return ['messageId' => $messageId, 'ok' => true];
} catch (SmppException $e) {
return ['ok' => false, 'status' => $e->getCode()];
}
}
/** One message in each of the three CSMS spellings; Client::$csmsMethod is process-global, so this
* driver serves one request at a time by construction (see the top-of-file note). */
function doSendLong(array $body): array
{
global $clients;
$client = $clients[$body['name']];
Client::$csmsMethod = (int) $body['csmsMethod'];
$message = encodeBody($body['text'], SMPP::DATA_CODING_DEFAULT);
$from = new Address($body['from']);
$to = new Address($body['to']);
try {
$messageId = $client->sendSMS($from, $to, $message, null, SMPP::DATA_CODING_DEFAULT);
return ['lastMessageId' => $messageId, 'ok' => true];
} catch (SmppException $e) {
return ['ok' => false, 'status' => $e->getCode()];
}
}
function doReceive(array $body): array
{
global $clients;
$client = $clients[$body['name']];
$sms = $client->readSMS();
if ($sms === false) {
return ['ok' => true, 'received' => false];
}
return [
'dataCoding' => $sms->dataCoding,
'esmClass' => $sms->esmClass,
'from' => $sms->source->value,
'hex' => bin2hex($sms->message),
'isReceipt' => $sms instanceof DeliveryReceipt,
'ok' => true,
'received' => true,
'text' => decodeBody($sms->message, $sms->dataCoding),
'to' => $sms->destination->value,
];
}
const ROUTES = [
'/bind' => 'doBind',
'/enquireLink' => 'doEnquireLink',
'/receive' => 'doReceive',
'/sendLong' => 'doSendLong',
'/submit' => 'doSubmit',
'/unbind' => 'doUnbind',
];
// Minimal single-connection HTTP server: no framework, one request handled fully before the next
// is accepted - which is exactly what a synchronous, blocking SMPP client needs (see top note).
$listen = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
if ($listen === false) {
fwrite(STDERR, "listen failed: $errstr\n");
exit(1);
}
fwrite(STDERR, "php-smpp driver listening on 8080, target $targetHost:$targetPort\n");
while (true) {
$conn = @stream_socket_accept($listen, -1);
if ($conn === false) {
continue;
}
$requestLine = fgets($conn);
$method = 'GET';
$path = '/';
if ($requestLine !== false && preg_match('#^(\\S+)\\s+(\\S+)#', $requestLine, $m)) {
$method = $m[1];
$path = parse_url($m[2], PHP_URL_PATH) ?? '/';
}
$contentLength = 0;
while (($line = fgets($conn)) !== false && trim($line) !== '') {
if (preg_match('/^Content-Length:\\s*(\\d+)/i', $line, $m)) {
$contentLength = (int) $m[1];
}
}
$rawBody = '';
while (strlen($rawBody) < $contentLength) {
$chunk = fread($conn, $contentLength - strlen($rawBody));
if ($chunk === false || $chunk === '') {
break;
}
$rawBody .= $chunk;
}
$body = $rawBody === '' || $rawBody === false ? [] : (json_decode($rawBody, true) ?? []);
if ($path === '/health') {
$payload = ['ok' => true];
$status = 200;
} elseif (isset(ROUTES[$path])) {
try {
$payload = ROUTES[$path]($body);
$status = 200;
} catch (Throwable $e) {
$payload = ['error' => get_class($e) . ': ' . $e->getMessage()];
$status = 500;
}
} else {
$payload = ['error' => 'no such route'];
$status = 404;
}
$json = json_encode($payload);
$statusText = $status === 200 ? 'OK' : ($status === 404 ? 'Not Found' : 'Internal Server Error');
fwrite(
$conn,
"HTTP/1.1 $status $statusText\r\nContent-Type: application/json\r\nContent-Length: "
. strlen($json) . "\r\nConnection: close\r\n\r\n" . $json
);
fclose($conn);
}
-10
View File
@@ -1,10 +0,0 @@
FROM python:3.12.14-slim-bookworm
RUN pip install --no-cache-dir smpplib==2.2.4
WORKDIR /app
COPY driver.py .
EXPOSE 8080
ENTRYPOINT ["python3", "/app/driver.py"]
CMD ["node", "2775"]
-363
View File
@@ -1,363 +0,0 @@
#!/usr/bin/env python3
"""HTTP-driven python-smpplib ESME: binds named sessions against the target SMPP server and
performs one action per request, answering with the result as JSON. Kept alive as one process so a
session survives across requests, the way jsmpp's Java driver does (see AGENTS.md)."""
import json
import socket
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import smpplib.client
import smpplib.consts
import smpplib.exceptions
import smpplib.gsm
import smpplib.smpp
TARGET_HOST = sys.argv[1] if len(sys.argv) > 1 else "node"
TARGET_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 2775
GSM_TABLE = smpplib.gsm.GSM_CHARACTER_TABLE
MODES = {
"receiver": "bind_receiver",
"transceiver": "bind_transceiver",
"transmitter": "bind_transmitter",
}
def as_text(value):
return value.decode() if isinstance(value, bytes) else value
def gsm_decode(data: bytes) -> str:
"""Inverse of smpplib's own gsm_encode(), through its own (vendor-specific) table - used to
decode what this driver receives, so a mismatch against what was sent is smpplib's own table,
not a guess at the real GSM 03.38 one."""
chars = []
i = 0
while i < len(data):
byte = data[i]
if byte == 0x1B and i + 1 < len(data):
chars.append(GSM_TABLE[0x80 + data[i + 1]])
i += 2
else:
chars.append(GSM_TABLE[byte])
i += 1
return "".join(chars)
def decode_body(data: bytes, data_coding: int) -> str:
if data_coding in (0, 1):
return gsm_decode(data)
if data_coding == 3:
return data.decode("latin-1")
if data_coding == 8:
whole = len(data) - (len(data) % 2)
return data[:whole].decode("utf-16-be")
return data.hex()
class Session:
def __init__(self, client):
self.client = client
self.send_lock = threading.Lock()
self.received = []
self.acks = {}
self.ack_events = {}
self.reader_thread = None
self.reader_running = False
self.reader_error = None
def wait_ack(self, sequence, budget=8.0):
event = self.ack_events.setdefault(sequence, threading.Event())
event.wait(budget)
return self.acks.get(sequence)
SESSIONS = {}
SESSIONS_LOCK = threading.Lock()
def session_for(name):
with SESSIONS_LOCK:
return SESSIONS[name]
def do_bind(body):
name = body["name"]
mode = body["mode"]
timeout_secs = float(body.get("timeoutSecs", 5))
client = smpplib.client.Client(TARGET_HOST, TARGET_PORT, timeout=timeout_secs, allow_unknown_opt_params=True)
client.connect()
kwargs = {"system_id": body["systemId"], "password": body["password"]}
if body.get("interfaceVersion") is not None:
kwargs["interface_version"] = int(body["interfaceVersion"])
getattr(client, MODES[mode])(**kwargs)
session = Session(client)
def on_received(pdu, **_kwargs):
data = pdu.short_message or b""
session.received.append({
"dataCoding": pdu.data_coding,
"esmClass": pdu.esm_class,
"from": as_text(pdu.source_addr),
"hex": data.hex(),
"text": decode_body(data, pdu.data_coding),
"to": as_text(pdu.destination_addr),
})
return smpplib.consts.SMPP_ESME_ROK
def on_sent(pdu, **_kwargs):
session.acks[pdu.sequence] = {
"messageId": as_text(getattr(pdu, "message_id", None)),
"status": int(pdu.status),
}
session.ack_events.setdefault(pdu.sequence, threading.Event()).set()
def on_error_pdu(pdu):
# Overrides the default handler, which raises: a refusing status must reach
# message_sent_handler like any other response, not tear down the read loop.
if pdu.command == "submit_sm_resp":
on_sent(pdu)
client.set_message_received_handler(on_received)
client.set_message_sent_handler(on_sent)
client.set_error_pdu_handler(on_error_pdu)
with SESSIONS_LOCK:
SESSIONS[name] = session
return {"ok": True}
def do_start_reader(body):
session = session_for(body["name"])
auto_send_enquire_link = bool(body.get("autoSendEnquireLink", True))
if session.reader_running:
return {"ok": True}
def run():
session.reader_running = True
try:
while True:
session.client.read_once(auto_send_enquire_link=auto_send_enquire_link)
except Exception as exc: # noqa: BLE001 - recorded, not raised: this is a driver thread
session.reader_error = f"{type(exc).__name__}: {exc}"
finally:
session.reader_running = False
session.reader_thread = threading.Thread(target=run, daemon=True)
session.reader_thread.start()
return {"ok": True}
def encode_body(text, data_coding):
if data_coding == 0:
return smpplib.gsm.gsm_encode(text)
if data_coding == 3:
return text.encode("latin-1")
if data_coding == 8:
return text.encode("utf-16-be")
raise ValueError(f"unsupported dataCoding {data_coding}")
def do_submit(body):
# Does not wait for the submit_sm_resp: a single-segment message is only answered once the
# caller's own "sms" handler calls sendResp(), which the caller can only do after seeing this
# call return - waiting here would deadlock exactly that handshake. Poll /ack for the result.
session = session_for(body["name"])
data_coding = int(body["dataCoding"])
payload = encode_body(body.get("text", ""), data_coding) if "text" in body else b""
if body.get("extraHex"):
payload += bytes.fromhex(body["extraHex"])
with session.send_lock:
pdu = session.client.send_message(
source_addr=body["from"],
destination_addr=body["to"],
short_message=payload,
data_coding=data_coding,
esm_class=int(body.get("esmClass", 0)),
)
sequence = pdu.sequence
return {"ok": True, "sequence": sequence}
def do_ack(query):
session = session_for(query["name"][0])
sequence = int(query["sequence"][0])
ack = session.acks.get(sequence)
if ack is None:
return {"found": False, "ok": True}
return {"found": True, "messageId": ack["messageId"], "ok": True, "status": ack["status"]}
def do_submit_long(body):
session = session_for(body["name"])
data_coding = int(body["dataCoding"])
parts, encoding, esm_class = smpplib.gsm.make_parts(body["text"], encoding=data_coding, use_udhi=True)
results = []
for part in parts:
with session.send_lock:
pdu = session.client.send_message(
source_addr=body["from"],
destination_addr=body["to"],
short_message=part,
data_coding=encoding,
esm_class=esm_class,
)
sequence = pdu.sequence
ack = session.wait_ack(sequence, budget=8)
results.append(ack)
return {"ok": all(results), "parts": len(parts), "results": results}
def do_enquire_link(body):
session = session_for(body["name"])
with session.send_lock:
pdu = smpplib.smpp.make_pdu("enquire_link", client=session.client)
session.client.send_pdu(pdu)
return {"ok": True}
def do_received(name):
session = session_for(name)
return {"ok": True, "received": session.received}
def do_status(name):
session = session_for(name)
return {
"ok": True,
"readerError": session.reader_error,
"readerRunning": session.reader_running,
"receivedCount": len(session.received),
}
def do_idle_silent(body):
"""Sleeps `seconds` sending nothing at all - no reader thread, no enquire_link - then does one
read attempt to say whether the peer (our server) closed the link while it was silent."""
session = session_for(body["name"])
time.sleep(float(body["seconds"]))
session.client._socket.settimeout(2)
try:
session.client.read_pdu()
return {"closed": False, "ok": True}
except socket.timeout:
return {"closed": False, "ok": True}
except smpplib.exceptions.ConnectionError:
return {"closed": True, "ok": True}
def do_unbind(body):
session = session_for(body["name"])
try:
session.client.unbind()
except Exception: # noqa: BLE001 - best-effort teardown
pass
session.client.disconnect()
with SESSIONS_LOCK:
del SESSIONS[body["name"]]
return {"ok": True}
ROUTES = {
"/bind": lambda body, _query: do_bind(body),
"/enquireLink": lambda body, _query: do_enquire_link(body),
"/idleSilent": lambda body, _query: do_idle_silent(body),
"/startReader": lambda body, _query: do_start_reader(body),
"/submit": lambda body, _query: do_submit(body),
"/submitLong": lambda body, _query: do_submit_long(body),
"/unbind": lambda body, _query: do_unbind(body),
}
GET_ROUTES = {
"/ack": do_ack,
"/received": lambda query: do_received(query["name"][0]),
"/status": lambda query: do_status(query["name"][0]),
}
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
def _respond(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/health":
self._respond(200, {"ok": True})
return
parsed = urlparse(self.path)
handler = GET_ROUTES.get(parsed.path)
if handler is None:
self._respond(404, {"error": "no such route"})
return
try:
self._respond(200, handler(parse_qs(parsed.query)))
except Exception as exc: # noqa: BLE001 - surfaced to the caller, not the process
self._respond(500, {"error": f"{type(exc).__name__}: {exc}"})
def do_POST(self):
handler = ROUTES.get(self.path)
if handler is None:
self._respond(404, {"error": "no such route"})
return
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
body = json.loads(raw or b"{}")
try:
self._respond(200, handler(body, None))
except Exception as exc: # noqa: BLE001 - surfaced to the caller, not the process
self._respond(500, {"error": f"{type(exc).__name__}: {exc}"})
def main():
server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)
print(f"python-smpplib driver listening on 8080, target {TARGET_HOST}:{TARGET_PORT}", file=sys.stderr)
server.serve_forever()
if __name__ == "__main__":
main()
-32
View File
@@ -1,32 +0,0 @@
# 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"]
@@ -1,17 +0,0 @@
#!/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 "$@"
-30
View File
@@ -1,30 +0,0 @@
# SMPPSim's own site (seleniumsoftware.com) answers 522; kwahome/smpp-sim-docker vendors the last
# surviving 2.6.11 build (see ../../research/smsc-simulators.md #1). Cloned at a pinned commit here
# rather than vendored into this repo.
FROM debian:13.2-slim AS source
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
ARG SMPPSIM_COMMIT=bc299828af9046ab290da3b4957dfbc472f02bfb
RUN git clone https://github.com/kwahome/smpp-sim-docker.git /src \
&& cd /src && git checkout "${SMPPSIM_COMMIT}"
# SMPPSim 2.6.x targets Java 6/7; runs unmodified on this 8 JRE.
FROM eclipse-temurin:8u452-b09-jre
WORKDIR /app
COPY --from=source /src/SMPPSim/smppsim.jar ./smppsim.jar
COPY --from=source /src/SMPPSim/lib ./lib
COPY --from=source /src/SMPPSim/www ./www
COPY --from=source /src/SMPPSim/mo ./mo
COPY --from=source /src/SMPPSim/conf/logging.properties ./conf/logging.properties
COPY *.props ./conf/
EXPOSE 2775 8884
ENTRYPOINT ["java", "-Djava.net.preferIPv4Stack=true", "-Djava.util.logging.config.file=conf/logging.properties", "-jar", "smppsim.jar"]
CMD ["conf/smppsim.props"]
@@ -1,51 +0,0 @@
SMPP_PORT=2775
SMPP_CONNECTION_HANDLERS=20
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
MESSAGE_STATE_CHECK_FREQUENCY=200
MAX_TIME_ENROUTE=300
DELAY_DELIVERY_RECEIPTS_BY=0
PERCENTAGE_THAT_TRANSITION=100
PERCENTAGE_DELIVERED=0
PERCENTAGE_UNDELIVERABLE=0
PERCENTAGE_ACCEPTED=100
PERCENTAGE_REJECTED=0
DISCARD_FROM_QUEUE_AFTER=60000
HTTP_PORT=8884
HTTP_THREADS=4
DOCROOT=www
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
INJECT_MO_PAGE=/inject_mo.htm
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
PASSWORDS=password,password,password
OUTBIND_ENABLED=false
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
OUTBIND_ESME_PORT=2776
OUTBIND_ESME_SYSTEMID=smppclient1
OUTBIND_ESME_PASSWORD=password
DELIVERY_MESSAGES_PER_MINUTE=0
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
LOOPBACK=false
ESME_TO_ESME=false
OUTBOUND_QUEUE_MAX_SIZE=1000
INBOUND_QUEUE_MAX_SIZE=1000
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
DECODE_PDUS_IN_LOG=true
CAPTURE_SME_BINARY=false
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
CAPTURE_SMPPSIM_BINARY=false
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
CAPTURE_SME_DECODED=false
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
CAPTURE_SMPPSIM_DECODED=false
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
CALLBACK=false
CALLBACK_ID=SIM1
CALLBACK_TARGET_HOST=localhost
CALLBACK_PORT=3333
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
SMSCID=SMPPSim
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
@@ -1,51 +0,0 @@
SMPP_PORT=2775
SMPP_CONNECTION_HANDLERS=20
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
MESSAGE_STATE_CHECK_FREQUENCY=200
MAX_TIME_ENROUTE=300
DELAY_DELIVERY_RECEIPTS_BY=8000
PERCENTAGE_THAT_TRANSITION=100
PERCENTAGE_DELIVERED=100
PERCENTAGE_UNDELIVERABLE=0
PERCENTAGE_ACCEPTED=0
PERCENTAGE_REJECTED=0
DISCARD_FROM_QUEUE_AFTER=60000
HTTP_PORT=8884
HTTP_THREADS=4
DOCROOT=www
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
INJECT_MO_PAGE=/inject_mo.htm
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
PASSWORDS=password,password,password
OUTBIND_ENABLED=false
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
OUTBIND_ESME_PORT=2776
OUTBIND_ESME_SYSTEMID=smppclient1
OUTBIND_ESME_PASSWORD=password
DELIVERY_MESSAGES_PER_MINUTE=0
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
LOOPBACK=false
ESME_TO_ESME=false
OUTBOUND_QUEUE_MAX_SIZE=1000
INBOUND_QUEUE_MAX_SIZE=1000
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
DECODE_PDUS_IN_LOG=true
CAPTURE_SME_BINARY=false
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
CAPTURE_SMPPSIM_BINARY=false
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
CAPTURE_SME_DECODED=false
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
CAPTURE_SMPPSIM_DECODED=false
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
CALLBACK=false
CALLBACK_ID=SIM1
CALLBACK_TARGET_HOST=localhost
CALLBACK_PORT=3333
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
SMSCID=SMPPSim
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
@@ -1,51 +0,0 @@
SMPP_PORT=2775
SMPP_CONNECTION_HANDLERS=20
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
MESSAGE_STATE_CHECK_FREQUENCY=200
MAX_TIME_ENROUTE=300
DELAY_DELIVERY_RECEIPTS_BY=0
PERCENTAGE_THAT_TRANSITION=100
PERCENTAGE_DELIVERED=100
PERCENTAGE_UNDELIVERABLE=0
PERCENTAGE_ACCEPTED=0
PERCENTAGE_REJECTED=0
DISCARD_FROM_QUEUE_AFTER=60000
HTTP_PORT=8884
HTTP_THREADS=4
DOCROOT=www
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
INJECT_MO_PAGE=/inject_mo.htm
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
PASSWORDS=password,password,password
OUTBIND_ENABLED=true
OUTBIND_ESME_IP_ADDRESS=node
OUTBIND_ESME_PORT=2776
OUTBIND_ESME_SYSTEMID=smppclient1
OUTBIND_ESME_PASSWORD=password
DELIVERY_MESSAGES_PER_MINUTE=0
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
LOOPBACK=false
ESME_TO_ESME=false
OUTBOUND_QUEUE_MAX_SIZE=1000
INBOUND_QUEUE_MAX_SIZE=1000
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
DECODE_PDUS_IN_LOG=true
CAPTURE_SME_BINARY=false
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
CAPTURE_SMPPSIM_BINARY=false
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
CAPTURE_SME_DECODED=false
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
CAPTURE_SMPPSIM_DECODED=false
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
CALLBACK=false
CALLBACK_ID=SIM1
CALLBACK_TARGET_HOST=localhost
CALLBACK_PORT=3333
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
SMSCID=SMPPSim
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
@@ -1,51 +0,0 @@
SMPP_PORT=2775
SMPP_CONNECTION_HANDLERS=20
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
MESSAGE_STATE_CHECK_FREQUENCY=100
MAX_TIME_ENROUTE=200
DELAY_DELIVERY_RECEIPTS_BY=0
PERCENTAGE_THAT_TRANSITION=100
PERCENTAGE_DELIVERED=100
PERCENTAGE_UNDELIVERABLE=0
PERCENTAGE_ACCEPTED=0
PERCENTAGE_REJECTED=0
DISCARD_FROM_QUEUE_AFTER=200
HTTP_PORT=8884
HTTP_THREADS=4
DOCROOT=www
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
INJECT_MO_PAGE=/inject_mo.htm
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
PASSWORDS=password,password,password
OUTBIND_ENABLED=false
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
OUTBIND_ESME_PORT=2776
OUTBIND_ESME_SYSTEMID=smppclient1
OUTBIND_ESME_PASSWORD=password
DELIVERY_MESSAGES_PER_MINUTE=0
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
LOOPBACK=false
ESME_TO_ESME=false
OUTBOUND_QUEUE_MAX_SIZE=1
INBOUND_QUEUE_MAX_SIZE=1000
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
DECODE_PDUS_IN_LOG=true
CAPTURE_SME_BINARY=false
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
CAPTURE_SMPPSIM_BINARY=false
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
CAPTURE_SME_DECODED=false
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
CAPTURE_SMPPSIM_DECODED=false
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
CALLBACK=false
CALLBACK_ID=SIM1
CALLBACK_TARGET_HOST=localhost
CALLBACK_PORT=3333
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
SMSCID=SMPPSim
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
@@ -1,51 +0,0 @@
SMPP_PORT=2775
SMPP_CONNECTION_HANDLERS=20
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
MESSAGE_STATE_CHECK_FREQUENCY=200
MAX_TIME_ENROUTE=300
DELAY_DELIVERY_RECEIPTS_BY=0
PERCENTAGE_THAT_TRANSITION=100
PERCENTAGE_DELIVERED=0
PERCENTAGE_UNDELIVERABLE=0
PERCENTAGE_ACCEPTED=0
PERCENTAGE_REJECTED=100
DISCARD_FROM_QUEUE_AFTER=60000
HTTP_PORT=8884
HTTP_THREADS=4
DOCROOT=www
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
INJECT_MO_PAGE=/inject_mo.htm
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
PASSWORDS=password,password,password
OUTBIND_ENABLED=false
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
OUTBIND_ESME_PORT=2776
OUTBIND_ESME_SYSTEMID=smppclient1
OUTBIND_ESME_PASSWORD=password
DELIVERY_MESSAGES_PER_MINUTE=0
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
LOOPBACK=false
ESME_TO_ESME=false
OUTBOUND_QUEUE_MAX_SIZE=1000
INBOUND_QUEUE_MAX_SIZE=1000
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
DECODE_PDUS_IN_LOG=true
CAPTURE_SME_BINARY=false
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
CAPTURE_SMPPSIM_BINARY=false
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
CAPTURE_SME_DECODED=false
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
CAPTURE_SMPPSIM_DECODED=false
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
CALLBACK=false
CALLBACK_ID=SIM1
CALLBACK_TARGET_HOST=localhost
CALLBACK_PORT=3333
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
SMSCID=SMPPSim
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
@@ -1,51 +0,0 @@
SMPP_PORT=2775
SMPP_CONNECTION_HANDLERS=20
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
MESSAGE_STATE_CHECK_FREQUENCY=200
MAX_TIME_ENROUTE=300
DELAY_DELIVERY_RECEIPTS_BY=0
PERCENTAGE_THAT_TRANSITION=100
PERCENTAGE_DELIVERED=100
PERCENTAGE_UNDELIVERABLE=0
PERCENTAGE_ACCEPTED=0
PERCENTAGE_REJECTED=0
DISCARD_FROM_QUEUE_AFTER=60000
HTTP_PORT=8884
HTTP_THREADS=4
DOCROOT=www
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
INJECT_MO_PAGE=/inject_mo.htm
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
PASSWORDS=password,password,password
OUTBIND_ENABLED=false
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
OUTBIND_ESME_PORT=2776
OUTBIND_ESME_SYSTEMID=smppclient1
OUTBIND_ESME_PASSWORD=password
DELIVERY_MESSAGES_PER_MINUTE=0
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
LOOPBACK=false
ESME_TO_ESME=false
OUTBOUND_QUEUE_MAX_SIZE=1000
INBOUND_QUEUE_MAX_SIZE=1000
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
DECODE_PDUS_IN_LOG=true
CAPTURE_SME_BINARY=false
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
CAPTURE_SMPPSIM_BINARY=false
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
CAPTURE_SME_DECODED=false
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
CAPTURE_SMPPSIM_DECODED=false
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
CALLBACK=false
CALLBACK_ID=SIM1
CALLBACK_TARGET_HOST=localhost
CALLBACK_PORT=3333
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
DELIVERY_RECEIPT_OPTIONAL_PARAMS=false
SMSCID=SMPPSim
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
@@ -1,51 +0,0 @@
SMPP_PORT=2775
SMPP_CONNECTION_HANDLERS=20
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
MESSAGE_STATE_CHECK_FREQUENCY=200
MAX_TIME_ENROUTE=300
DELAY_DELIVERY_RECEIPTS_BY=0
PERCENTAGE_THAT_TRANSITION=100
PERCENTAGE_DELIVERED=100
PERCENTAGE_UNDELIVERABLE=0
PERCENTAGE_ACCEPTED=0
PERCENTAGE_REJECTED=0
DISCARD_FROM_QUEUE_AFTER=60000
HTTP_PORT=8884
HTTP_THREADS=4
DOCROOT=www
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
INJECT_MO_PAGE=/inject_mo.htm
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
PASSWORDS=password,password,password
OUTBIND_ENABLED=false
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
OUTBIND_ESME_PORT=2776
OUTBIND_ESME_SYSTEMID=smppclient1
OUTBIND_ESME_PASSWORD=password
DELIVERY_MESSAGES_PER_MINUTE=0
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
LOOPBACK=false
ESME_TO_ESME=false
OUTBOUND_QUEUE_MAX_SIZE=1000
INBOUND_QUEUE_MAX_SIZE=1000
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
DECODE_PDUS_IN_LOG=true
CAPTURE_SME_BINARY=false
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
CAPTURE_SMPPSIM_BINARY=false
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
CAPTURE_SME_DECODED=false
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
CAPTURE_SMPPSIM_DECODED=false
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
CALLBACK=false
CALLBACK_ID=SIM1
CALLBACK_TARGET_HOST=localhost
CALLBACK_PORT=3333
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
SMSCID=SMPPSim
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false

Some files were not shown because too many files have changed in this diff Show More