73 KiB
Round 1: drafts A and B
Draft A, junior seat
-
Hardest places, ranked
src/outgoing-requests.ts:75-113(OutgoingRequests.request/requestDuringDrain/carrier) together withsrc/session.ts:336-371(linkLost,end,nextLinkExpected). Whether a send is refused, waits or retries depends onlife,link.canCarry()andreconnectLoop. Those live inSessionand reach here only through the threeLinkViewclosures, so reading one file means holding the other's state in my head. Line 82,closing() && current().canCarry(), beat me until I tracedcarrier()→nextExpected()→life === 'open'. Its comment ("refused as closed further on") points at the answer without giving it.linkLostreadsnextLinkExpected()beforeclose(), and only the comment explains why. That comment helped; the rest stayed half-opaque.src/held-messages.ts:40-170(HeldMessage,HeldMessages.offer), withsrc/sms.ts:77-162andsrc/session.ts:104. A message has six exits across three files: aworkinglistener counter,answered()deferring bysetImmediate, aWeakMapfromSmsto hold, andemit()returning false meaning release.captureRejectionSymbolcallsthis.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.src/pdu.ts:84-136(resolveShortMessage,resolveBody). TheCodingSourceidea is hard:data_codingis rewritten from whichever body "owns" it, an empty buffer counts asmessage_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: atpdu.ts:249readParamspasses the already-readsm_lengthto every wire type'sread. Only the comment atdefs/commands.ts:19-23hints at it. Partly resolved.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.ASCIImeans 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.src/dlr.ts:157-239(messageType,receiptStatus,dlrFromPdu). There are four message types, TLV-over-body precedence for both id and state, and anunmarkedcase 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.src/reassembly.ts:187-207(Reassembler.trim) withsrc/expiring-groups.ts:18,70-88.weigh()may evict the very group being added, andanswered = parts.size - 1subtracts 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.src/dlr-merger.ts:150-172(open,close).close()does not close a group: it moves the base into a secondExpiringGroupscalledspent. The misleading name cost me a reread. The class doc resolved it.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", soleftOfclamps to 1. Small but inverted.
-
Least want to modify:
HeldMessages/HeldMessage(src/held-messages.ts). Its lifetime is decided by timing (setImmediateso thatsendDlrstill goes out past a drain), by listener counts, by identity checks against reused sequence numbers, and by callers insession.tsandsms.ts. A change to any exit risks a drain that hangs or one that ends early, and nothing local would show it. -
Expected hard, found easy:
PduFramer,PendingRequests,SendWindow,ReconnectLoop, and the TLV table's type-level keying (defs/tlvs.ts).defs/types.tsis 684 lines but repetitive and uniform.client.tsandserver.tsare shallow and read top-down. -
Prose debt:
-
Needed:
- The README encoding table, to learn that
ASCIImeans GSM 7-bit. - The AGENTS "GSM 7-bit is sent unpacked" section, to see why
segmentUnitsholds 153 against 134. - The README "Server in depth" and "Shutdown" sections, to understand
answeredOnArrivaland 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 ignoresshutdownTimeout: 0" had to be recovered from code comments.
- The README encoding table, to learn that
-
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.
-
-
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.tsanddrain.ts, with no single entry point. - Locality 5 (Honest middle):
Sessioninjects its state as closures (LinkView,Link.on) and passes itself back intoHeldMessagesandIncomingRequests, and correctness hangs on ordering that is only named in comments (linkLostbeforeclose,setImmediateinanswered). - Shape 6 (between 5 and 7): fan-out stays bounded per level, but several names lie:
ASCIIfor GSM,DlrMerger.closefor "mark spent",stringfor a length-prefixed Octet String next tocstring, andanswered()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
answeredOnArrivalmeans) needs the README open beside the code. - Overall 6: capped at locality plus one by the cross-file lifecycle state.
- Navigation 7 (Predictable): file names match behaviours one-to-one, but "what happens to a send during shutdown" lives across
SCORES nav=7 loc=5 shape=6 self=6 overall=6
Draft A, mid seat
-
Hardest places, hardest first
src/held-messages.ts:252-435,HeldMessage/HeldMessages(andsession.ts:95-107, thecaptureRejectionSymboloverride). There are six exits, each in a different method. I had to trace this chain across files: a listener rejects, Node'scaptureRejectionscalls the session, the session callsthis.link.held.rejected(rest[0]), a WeakMap is searched by object identity,listenerGaveUp()counts down from alistenerCount('sms')taken when the message was offered,answered()waits a turn insetImmediate,release()compares the array by identity,settle()runs, and finallyIdleWaiterswakes the drain indrain.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 thatsend()choosingsendPastDrainexists only becauseOutgoingRequests.requestrefuses sends while closing.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 tracingcarrier()to see thatnextExpected()is false while closing.responseTimeoutis reused as the deadline for waiting on a link,deadline === 0means forever, and the retry loop depends onretryOnNextLinkfromLink.send. TheLinkViewclosures readSessionprivate state from a distance. The comments resolved most of it after two reads.src/session.ts:208-366,unbind/drain/comeBackUp/linkLost/end. The ordering does the work.unbinddrains, then goes around the closing refusal viarequestOnCurrentLink.closedOnUnbinddecides which error wins.comeBackUpsetsthis.linkbefore the bind succeeds, andlinkLostmust readnextLinkExpected()beforeclose()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.src/reassembly.ts:187-207,Reassembler.trim, withsrc/expiring-groups.ts:245-315,ExpiringGroups.weigh.ExpiringGroupsapplies its three limits differently: the owner checksfull, the owner callstakeExpired, and onlyweighevicts. Map insertion order stands in for age, andset()re-inserts, so a replaced entry becomes the newest.trimcan evict its own group, and it then countssize - 1as lost because the refused segment "stays with the peer". The comments state each rule, but checking the arithmetic took three rereads.src/pdu.ts:84-136,resolveShortMessage/resolveBody, pluspdu.ts:249.CodingSourcedecides whethershort_messageormessage_payloadis allowed to setdata_coding, and an empty buffer flips the answer.readParamspassessm_lengthas the length argument to every field read, and onlybuffer.readuses it. That is a hidden coupling that neither line mentions. With no SMPP background this stayed half-opaque.src/defs/encodings.ts:1,105-190,EncodingNameandmessageClassEncoding/encodingByDataCoding. The name'ASCII'means GSM 03.38, and nothing says so untildataCodingByEncoding's comment. It also misleads inmessage.ts:343andmessage.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.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. OtherwiseIncomingRequests.onDelivery(incoming-requests.ts:152) quietly reroutes it toonMessage. The rule is local and commented, but it only makes sense with the spec'sesm_classbits in mind.src/client.ts:258-330,keepTrying/initialAttempts. There is a secondReconnectLoopoutside the session, a freshSessionper attempt, and alastErrcaptured in closures. The code itself is clear; the cost was noticing that there are two loops.
-
Unit I would least want to modify:
HeldMessages/HeldMessage(src/held-messages.ts). Its correctness depends on timing (setImmediate), a listener count taken atemittime, identity lookups (the WeakMap, and array identity in the store), and callers insession.ts,incoming-requests.ts,sms.tsanddrain.tsthat each use a different exit. A change there fails as a hung or cut-short shutdown, which is hard to see in a test. -
Expected hard, found easy: the wire codec.
defs/types.tsis long but uniform.PduFramer,parseTlvs/writeTlvs,readOptionalParams(its NULL-pad rule is commented),ReconnectLoop,bind-direction.ts,sms-id.tsand theResult<T>convention were all quick.udh.ts'sconcatInfoexplains its walk over the header elements well enough for a newcomer to the domain. -
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 andsar_*. The only one is theLinkEndcomment 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.tsmessagesBudget). The index cost scrolling and gave nothing the code did not. - I opened no tests.
- The AGENTS.md architecture map was cheap and correct; it is how I found every file. The file list matches
- 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.
- Needed:
-
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, andLinkViewclosures readingSessionprivate state. The order-dependent sequences inSession.linkLostandunbindadd 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,answeredis both a mutable{ smsId }holder (line 81) and a handler function (line 69). HeldMessage.held.heldchains through two different things both calledheld.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_codingandesm_classbit logic inencodings.tsanddlr.tscannot 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.
- Navigation 7: at the "predictable" anchor. The one-line-per-file map and concept-named files (
SCORES nav=7 loc=5 shape=6 self=6 overall=6
Draft A, senior seat
-
Hardest places, ranked
-
src/held-messages.ts:40HeldMessage, andHeldMessages.offerat:148. Following this one flow meant holding five files at once:sms.ts:127sendRespand:210sendDlr.- The
setImmediateinanswered()at:58. HeldMessage.sendat:77, which picks betweensendPastDrainandsession.sendby askingisHeld().OutgoingRequests.requestDuringDrain.Session'scaptureRejectionSymbolatsession.ts:104, which gets back to the hold through aWeakMapkeyed on theSms.
A
sendDlr()gets past a drain only while the release has not yet happened, and that is one event-loop turn.workingislistenerCount('sms')taken at offer time, and the guardedemitreturning false feeds exit 3. The six-exits comment and the README's Shutdown section settled it, but only after I had read both. -
src/outgoing-requests.ts:75request, with:90requestDuringDrain,:115bindOnCurrentLink,:133requestOnCurrentLinkand:160carrier. 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 skipattemptOn.
I followed it in the end, but did not come away sure of the edge cases.
- Line 94,
-
src/session.ts:234answer, withincoming-requests.ts:85andsms.ts:127.sendReturnalways writes tothis.link, the current link. The rule that "an answer belongs to the link the message arrived on" is held by callers checkinglink.isClosed()orlostLink()before they call, in two separate places.sendReturnnever enforces it. I had to hunt for this, and only the decision titles in AGENTS.md told me the rule exists. -
src/pdu.ts:84resolveShortMessage/:113resolveBody.CodingSourcedecides whethershort_messageormessage_payloadsetsdata_coding. I had to hold these cases at once:- Buffer or string or absent.
- Empty or non-empty.
data_codinggiven or not.- An empty
short_messagethat still makesmessage_payloadthe source.
The type comment at
:74helps. It still took two reads. -
src/reassembly.ts:111collect/:188trim, on top ofexpiring-groups.ts:18.ExpiringGroupsenforces its limits unevenly:set()never evicts andweigh()does.fullis only advisory.onSweepmust itself calltakeExpired().
trimcountsparts.size - 1because 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. -
src/session.ts:208unbindand:336linkLost. Inunbind, the three booleanswasOpen,closedOnUnbindanddraineddecide which error wins. InlinkLost, "readnextLinkExpectedbeforeclose()" depends onlink.close()callingreassembler.clear(), which emitssessionErrorsynchronously to a listener that might callclose(). That is state changed out of sight. The comment names the risk but not the path it takes. -
src/dlr-merger.ts:150open/:165close. Hereclosemeans "mark as spent", not "tear down", andspentis a secondExpiringGroups<true>with its own cap and eviction. The class comment explains the purpose, but the method name misleads. -
src/client.ts:286keepTrying/:263initialAttempts. There are two differentReconnectLoopowners: the session's loop, and a separate one that runs only for the first connect. There is also alastErrclosure and a comment aboutunref: false. It was readable once I saw thatfromStartbuilds a freshSessionfor every attempt.
-
-
The unit I would least want to modify:
OutgoingRequeststogether with its callersHeldMessage.sendandSession.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. -
Expected hard, found easy:
- The codec:
defs/types.tsand 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.
- The codec:
-
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
unbindis clean. Cost was moderate: all of it in two files I had already read, but the "one turn later" rule forsendDlris stated in full only in README step 1.A comment that is wrong:
SessionOptions.shutdownTimeoutsays it bounds only "the requests already on the wire", butdrain.ts:25also uses it for held messages.Defaults are scattered with no pointer between them:
- A separate
defaultsobject in each ofclient.ts,server.tsandsession-options.ts. backoffDefaultsinreconnect-loop.ts.defaultMaxOctetsinreassembly.ts, which duplicatesmaxHeldOctets.
Finding where the default for a given option lives took a search.
- A separate
-
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 theConcatInfo/udhLengthcomments.
- The AGENTS 0.4.0 defects table: history, and it never helped me read
I did not open any test.
-
-
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 "
sendDlrrefused during shutdown" lands across four files, and "which default" across three objects all calleddefaults. -
Locality 6 (between 5 and 7): the codec, defs, dlr and message modules are fully local. The session layer is not:
Sessionpasses itself intoIncomingRequests,HeldMessagesandcreateSms, the link-answer rule is enforced by callers, and the drain bypass depends on asetImmediatein another file. -
Shape 6 (between 5 and 7): fan-out is bounded and most names are true. Some are not:
ASCIImeans GSM 03.38.HeldMessages.full()sweeps, logs and flips state.DlrMerger.closemeans "mark as spent".Session.linkis documented as "the latest socket".
Four near-identical send entry points on
OutgoingRequestsalso 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.
Linkalso owns thePendingRequests, theReassemblerand theHeldMessages. 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), whiledlr.tsparses them. Writing and reading receipts are split across two files. - Smaller surprises (low).
reassembly.tsexportsdecodeSegments, whichsms.tsuses to buildsms.message.message.tsalso holdssmppTimeandsmppDate. - drain, idle-waiters, retained-pdu, error-from (cheap). Each turned out as guessed.
drain.tsis 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:
idlemeans two things.HeldMessages.idle(),SendWindow.idle(),OutgoingRequests.idle()andIdleWaitersmean "wait until the count reaches zero".idleTimeoutand "closing an idle peer" in link.ts mean the peer has gone silent.ExpiringGroupsenforces neither itsmaxnor its timeout (its own comment says owners must).DlrMerger.spentis anExpiringGroups<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()onSmsHandlersis a predicate named like an event.message.tsalso holdssmppTimeandsmppDate.
One concept with two or more names:
- Answering a request has four spellings:
sendReturn,pduReturn,Session.answer()andsendResp. - Letting a send past the drain has two:
sendPastDrainandrequestDuringDrain. - The socket has three: link,
sockand 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"), andHeldMessages.held, the inner ExpiringGroups. drainis bothSession.drain()(private) anddrain()in drain.ts.defaultsis three different objects: session-options.ts, client.ts and server.ts, withsystemIdin two of them.Waiter/waitingis 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
- 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.
- Settle on one verb for answering a request.
- Move receipt composition out of sms.ts next to the parsing in dlr.ts. Merge
collectReceipt(sms.ts:188) withcollectSent(send-sms.ts:274); they are near-duplicates. - Make
ExpiringGroupsenforce its ownmaxand weight, or rename it to say the caps are advisory. Today three owners each implement eviction differently:Reassembler.openplustrim,DlrMerger.dropOldestplusspent, andHeldMessages.full()with its own weight comparison. - Rename the "wait until zero" methods (
idle()→drained()), and give "held" one meaning. - Move
smppTimeandsmppDateinto their own module, and keep onedefaults.
What the structure gets right:
session.tsis 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.
Session.closeleads toSession.drain(session.ts:251).- That leads to
drain()(drain.ts:32). Its err text and warn logs already say which half stalled: "messages unanswered" or "requests unfinished". - Messages half:
HeldMessages.idleandHeldMessages.release(held-messages.ts:185-222), reached fromHeldMessage.answered()(held-messages.ts:57).- The gate is
sms.ts:159,if (!failure) handlers.answered(). If anysendReturnfor the message fails, the message is never released. Two ways that happens: ansmsIdthe 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 sawerrfromsendResp()and ignored it. - Release also works by object identity (
held.get(key) !== pduObjs), so check that too.
- The gate is
- Requests half:
sendDlrgoes past the drain throughrequestDuringDrain(outgoing-requests.ts:90). It holds a send-window slot until the peer answers thedeliver_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.
Linkis handed asessionthrough its options.HeldMessagesemits'sms'onSession.- A listener rejection comes back through
Session[captureRejectionSymbol]intothis.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:
setImmediateinanswered()exists so that asendDlr()called straight aftersendResp()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 touchesDlrMerger,ReassemblerandHeldMessagesat 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
EncodingNameunion. It spreads intomessage.ts:14(segmentUnits),send-sms.ts:92(dataCodingFor, with hard-coded 0x10/0x18),defs/encodings.tsandbitCount: 4 to 5 places.
- A rate limit lands cleanly at
6. Hardest places, ranked
src/held-messages.ts:40-223,HeldMessageandHeldMessages: six exits, release by identity, WeakMap for rejections,setImmediaterelease, back-reference to the session, the drain bypass.src/session.ts:95-107,[captureRejectionSymbol]: a rejected'sms'listener reaches the held messages through anunknownargument on the current link. Action at a distance.src/outgoing-requests.ts:75-137:request,requestDuringDrain,bindOnCurrentLinkandrequestOnCurrentLinkare four ways in with different bypass rules. The conditionclosing() && current().canCarry()(line 82) reads inverted until you notice the fall-through.src/expiring-groups.ts:19-147,ExpiringGroups: the contract is half-enforced, and each owner fills in the rest differently.src/dlr-merger.ts:68-184,DlrMerger:groupsplusspent, andclose()always marks an id spent.src/drain.ts:20-57withsrc/session.ts:251: budget arithmetic where 0 means forever and the messages half falls back toresponseTimeout.session-options.ts:63documentsshutdownTimeoutas covering only the requests on the wire, a partial truth next to the code that owns the behaviour.src/client.ts:228-330,bindOn,initialAttemptsandkeepTrying: a secondReconnectLoopoutsideSession, with a fresh session per attempt.src/pdu.ts:84-136,resolveShortMessageandresolveBody: which body field gets to setdata_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.tstodrain.tstoheld-messages.ts/sms.ts:159with 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:
LinkandSessionhold clean boundaries, but the held-message flow reaches back intoSessionthroughLinkoptions, relies onsetImmediateordering, and gets rejections throughcaptureRejectionson the current link. And three owners each re-enforceExpiringGroups' caps. - Shape: 6. Between the anchors: the flat L1 of 36 files breaks the bound and has no grouping. "held", "idle",
defaultsand "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
linkLostcomment, spec citations), and I needed no second document to follow a unit. It stays below 9 because of the partialshutdownTimeoutdoc atsession-options.ts:63and 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
-
Hardest places, ranked hardest first
src/outgoing-requests.ts:70-168,OutgoingRequests.request→requestPastDrain→carry→attempt. A request has four ways in. One is a recursive retry (carrycalls itself at :130) that fires only whenretryOnNextLink && link.awaitsNextLink(). Each hop asksLinkLifea 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.requestPastDrainis named for the caller that needs it (a receipt during a drain), not for what it does. TheUnansweredErrorwrap at :167 was clear. The rest stayed half-opaque.src/link-life.ts:74-171,LinkLife.transition/lose/end. There are three pieces of state:linkPhase,stoppinganddrops.stoppingis set in two places (:89, :167).dropsincrements both inloseand inend, andboundwhile stopping falls through tolose(). The effects returned are carried out elsewhere, insession.ts:269run(), 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 theupdoc ("a bound socket carries requests") and the charter's "a bind is what makes it one". I never resolved why the first link startsup.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 fromsession.ts:97, wherecaptureRejectionSymbolpassesrest[0]asunknown. It is then looked up in aWeakMap, andworkingis seeded fromsession.listenerCount('sms')at :161. Way 1 arrives through a callback built insms.ts. Way 3 depends onemitreturning false, both for no listener and for a throw (the override insession.ts:78). I pieced this together; it did not stay opaque, but it was the most action at a distance in the codebase.src/pdu.ts:84-136,resolveShortMessage/resolveBody.CodingSourcedecides which of two fields may setdata_coding. An empty buffer counts asmessage_payload, andsm_lengthis filled only sometimes. With no SMPP background I could not tell why an emptyshort_messagehands authority to the TLV until I readmessage-body.tsand the README's "Where the body is". After that it made sense, but it took two files and a doc.src/client.ts:228-350,bindOn/initialAttempts/keepTrying/client. ThefromStartpath builds a secondReconnectLoopoutside any session. Each attempt gets a freshSession, and alastErrclosure 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 insession.tsdrain()/apply('stopping'). It is correct, but invisible from here.src/defs/encodings.ts:151-198,messageClassOf/messageClassEncoding/encodingByDataCoding. This is bit-masking overdata_codinggroups 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.src/reassembly.ts:188-207,Reassembler.trim.ExpiringGroups.weigh()returns evicted groups, possibly including the current one.answered = parts.size - 1for the current group depends on the refused segment already being inparts. The comments at :200 and :125 resolved it after a reread.src/drain.ts:70-112,drain/answeringBudget. Two budgets with different zero-semantics, a fallback todefaults.responseTimeoutimported fromsession-options, and asetImmediateturn. The comments explain each step. It was harder than it looked, but it resolved.
-
The unit I would least want to modify:
OutgoingRequests.carrytogether withrequestPastDrain(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 producedlost→dropLink, so thatawaitsNextLink()is true. It also depends on the send-window slot being released infinallybefore the recursion. Nothing in the unit states those timing assumptions. A change would be guessed and then tested. -
Expected to be hard, found easy.
- The wire codec:
defs/types.tsis long but completely regular, and every read/write is range-checked. PduFramer,concatInfo,sms-id.ts,dlr.tsreceipt parsing (comments name the operators and spec sections).- The
Resultpattern. server.tshandleRequest.DlrMerger, whose severity table comment says exactly why it exists.- The session constructor, which reads as a wiring diagram.
- The wire codec:
-
Prose debt.
- Needed:
- The SMPP vocabulary: ESME vs SMSC,
deliver_smvssubmit_smdirection,esm_class,data_coding, TON/NPI, UDH vssar_*. 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
segmentUnitsinmessage.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-uppuzzle is exactly where I needed it.
- The SMPP vocabulary: ESME vs SMSC,
- 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 bothoutgoing-requests.tsandpending-requests.ts, and theidle()"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" insession.ts, which restatesPduTransport.
- Needed:
-
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.tsandsession.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'scaptureRejectionSymbol. 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:
ASCIImeans GSM 03.38,drain.tshousesIdleWaiters,requestPastDrainis named for a caller, and the link starts in phaseupbefore 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.
- 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
SCORES nav=7 loc=6 shape=6 self=5 overall=6
Draft B, mid seat
-
Hardest places, hardest first
src/link-life.ts:74LinkLife.transition(), withlose():148,end():159, andsrc/session.ts:263apply()/ :269run(). The table depends on two hidden variables besides the phase: thestoppingflag and thedropscounter.lose()sets the phase todownbefore it callsend(), and that is the only thing that stopsend()counting the same drop twice.'bound'while stopping goes throughlose()and ends up inend(). The effect order matters:dropLinkclears held, incoming and outgoing beforeemitClose. The phase names mislead too. The doc at :14 says "up: a bound socket carries requests", but the phase starts asup(: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 readingOutgoingRequests.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.- Shutdown, spread over
src/session.ts:237unbind()/ :300drain(),src/drain.ts:96drain()/ :70answeringBudget(),src/outgoing-requests.ts:70request()/ :85requestPastDrain(), andsrc/held-messages.tssendReceipt. To know whether a send is refused I had to hold five things at once:isStopping() && canCarry(), the "refused as closed further on" path throughLinkLife.refusal(), the receipt's route past the drain, thesetImmediateturn between the two waits, and the fallback forshutdownTimeout: 0.IdleWaiterslives indrain.tsbut servesSendWindowandHeldMessages, so I went to the wrong file for it once. The comments explain each step, but no single place explains the whole sequence. src/held-messages.ts:94offer()/ :117listenerRejected()/ :154keep(). Release path 3 depends onSession.emitbeing overridden (session.ts:78) to returnfalsewhen a listener throws. Path 2 depends oncaptureRejectionsrouting through session.ts:106 back into aWeakMaplookup by object identity. Theworkingcount is taken fromsession.listenerCount('sms')at keep time. That is action at a distance in both directions. The numbered "six ways" comment is what made it followable.src/client.ts:228bindOn(), :263initialAttempts(), :286keepTrying(). These are three layers of session creation forfromStart, with abort listeners added and removed at different points. The comment at :249 saysclose()has to reachstop()before its first await. That rule depends onSession.drain()callingapply('stopping')synchronously (session.ts:301). The comment resolved it, but it is an ordering dependency across files.src/pdu.ts:84resolveShortMessage()/ :113resolveBody(). TheCodingSourceidea (which of the two bodies gets to setdata_coding) has about six branches: empty buffer, non-empty buffer, a string that encodes to empty, the command having noshort_message, and a stringmessage_payload. I had no domain background for why the payload may overridedata_codingonly whenshort_messageis empty. The type comment at :74 got me about half of it.src/reassembly.ts:188trim()withsrc/expiring-groups.ts:70weigh().weigh()returns evicted groups, possibly including the current one, andtrimthen usesparts.size - 1for that one.ExpiringGroupsenforces its limits unevenly:maxnever,maxWeightonly inweigh. Its three users each handle that differently:HeldMessageschecks the weight itself, andDlrMergerruns two instances (groups+spent, dlr-merger.ts:150/:165). The class comment says all this, but I needed a second pass.src/defs/encodings.ts:162messageClassEncoding()/ :182encodingByDataCoding()/ :151messageClassOf(). 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.src/outgoing-requests.ts:114carry()/ :141attempt(). A recursive retry that shares one link budget, where a retry happens only whenretryOnNextLink && awaitsNextLink(). Insidecarry(),const held = await waitForLink()reuses the word "held", which elsewhere meansHeldMessages. Readable once you know the link model.
-
Least want to modify:
LinkLife.transition()together withSession.run(). Every lifecycle path goes through it (idle timeout, socket close, unreadable stream, unbind, close, rebind). The order of effects and thestopping/dropsside state are invariants that nothing in the types enforces. A wrong effect order would show up as a leaked pending request or a doublecloseevent somewhere far away. -
Expected hard, found easy: the wire codec.
defs/types.ts, TLV read/write andPduFramerare mechanical, range-checked and uniform. The same goes forPendingRequests,SendWindow,ReconnectLoop, the linear check chain insend-sms.ts, andudh.ts. Receipt parsing indlr.tswas clearer than I expected for an unfamiliar domain. -
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_smdirection 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 orsar_*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 whatdrain.tsimplements. - 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 onOutgoingRequestsandPendingRequests. /** Starts both timers over… */-style restatements inlink-timers.ts.
- Needed, and cheap to find:
-
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
IdleWaitersliving indrain.ts, and receipt-sending split betweensms.tsandOutgoingRequests.requestPastDrain. - Locality: 6. Between 5 and 7. Collaborators are injected and the lifecycle effects are an explicit list. But
HeldMessagesandIncomingRequestscall back intoSession(emitoverride semantics,listenerCount,close), and correctness depends on synchronous ordering (apply('stopping')before the first await, thesetImmediateindrain). - Shape: 7. The Session hub's fan-out is wide but flat (about 9 collaborators, each small). A few names lie: the
upphase before any bind, theASCIIencoding meaning GSM 03.38, the word "held" reused incarry(), and two differentonConnectedsignatures (aSessioninReconnectOptions, aSocketinReconnectLoopOptions). - 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_classanddata_codingbit 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-lifeandheld-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.
- 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
SCORES nav=8 loc=6 shape=7 self=7 overall=6
Draft B, senior seat
-
Hardest places, ranked hardest first
- 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.stoppingis set byapply('stopping').request()refuses only whenisStopping() && canCarry(). Otherwise it relies onawaitsNextLink()going false becauseretrying()checks!stopping, which I had to hunt for inlink-life.ts:132. - The two drain budgets differ in a way only the code shows. Messages fall back to
responseTimeout; requests getleftOf(deadline), clamped to at least 1 ms. There is also an ordering dependency on asetImmediateturn between the two waits (drain.ts:108). - The comments are correct but terse. I resolved it after two reads, plus the README "Shutdown" section.
- To answer "what does a send do during shutdown?" I had to hold four files at once.
- The held-message lifecycle:
src/held-messages.ts:94(offer),:117(listenerRejected) and:154(keep);src/session.ts:106;src/sms.ts:91-93and:151-161.- A hold ends in six ways, and they are spread across three files.
Session'scaptureRejectionSymbolreaches intoheldby identity, through aWeakMap.workingis a snapshot oflistenerCount('sms')taken when the message is kept. - The numbered "1–6" comments are what resolved it. Without them this was action at a distance.
- A hold ends in six ways, and they are spread across three files.
- The link state machine:
src/link-life.ts:74(transition),:148(lose) and:159(end).lose()setsdownand then callsend(), which rereadsattached()(now false).dropsis incremented in two places.- The initial
linkPhase = 'up'(:60) contradicts the type doc at:13-17, which saysupmeans "a bound socket carries requests". A fresh client or server socket isupbefore 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 tracedcomeBackUp(session.ts:359).
- Reassembly under the octet cap:
src/reassembly.ts:111(collect) and:188(trim), withsrc/expiring-groups.ts:70(weigh).- The segment is inserted before
trim.weighcan evict the current group itself, andanswered = parts.size - 1then excludes the refused segment from the loss. ExpiringGroupsenforces max, timeout and weight differently:fullis only a flag,weighevicts,setnever does. Its own doc comments make that explicit, which resolved it.
- The segment is inserted before
- Encoding a body under
data_coding:src/pdu.ts:84(resolveShortMessage) and:113(resolveBody).CodingSourcedecides whethershort_messageormessage_payloadmay overwritedata_coding. An empty encoded buffer flips the source tomessage_payload, and a Buffershort_messageof 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.
- The client's first connect:
src/client.ts:333(client),:286(keepTrying),:263(initialAttempts) and:228(bindOn).- There are two
ReconnectLoops: one outside any session forfromStart, and one inside each session. Each attempt builds and discards a wholeSession. bindOnrelies onclose()reachingstop()before its first await (the comment at:249). That ordering invariant lives insession.ts:300(draincallingapply('stopping')synchronously).- The comment named the ordering rule; checking it held took a look at
session.ts.
- There are two
DlrMerger.open/close:src/dlr-merger.ts:150and: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 onspentis 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.
- Server hook composition:
src/server.ts:208(handleRequest) withsrc/incoming-requests.ts:89(handle) and:147(unhandled).- What happens to a rebind or a pre-bind
unbindis decided half in each file, throughfalsereturn values.boundAs === undefinedmakesbindAllowsreturn true. - I resolved it by reading both. A smaller cost of the same kind:
pdu.ts:249passessm_lengthas thelengthargument to every wire type'sread, and onlybufferuses it. That implicit coupling depends on wire order.
- What happens to a rebind or a pre-bind
- The shutdown path:
-
The unit I would least want to modify:
OutgoingRequests(src/outgoing-requests.ts:70-168)- It has three entry points:
request,requestPastDrainandrequestOnCurrentLink. 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 fromsendDlr, bind and unbind. - The retry recursion in
carrydepends onLinkLife.awaitsNextLink(),pending.settleordering, and window release infinally. - Whether a request may be resent (goal 2) is decided here, and it hinges on the
retryOnNextLinkflag. A wrong edit silently duplicates billed traffic.
- It has three entry points:
-
Expected hard, found easy
- The codec:
defs/types.tswire types,PduFramer, andparseTlvs/writeTlvswith the typedTlvs. - GSM 03.38 escaping,
encodingByDataCoding, receipt text parsing (dlr.ts),sms-id.tsnormalisation,ReconnectLoopbackoff, andudh.tsIE walking. - Each is self-contained, total, and commented at the exact surprising line.
- The codec:
-
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 startingup. - 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) andreassembly.ts:45(defaultMaxOctets, duplicated bymaxHeldOctets). "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:101andreconnect-loop.ts:84and:140re-implementerrorFrominline.
- Needed:
-
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
upphase on an unbound socket,'ASCII'for GSM 03.38, "bound" meaning two things,DlrMerger.closemeaning "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:
- Public surface:
index.ts. - Endpoints:
client.ts(connect, bind, reconnect-from-start) andserver.ts(listener, auth, bind answering). - Session core:
session.ts,session-options.ts,bind-direction.ts. - Link lifecycle:
link-life,link-timers,reconnect-loop,pdu-transport,drain(shutdown?). - Outbound requests:
outgoing-requests,pending-requests,send-window,send-sms,unanswered-error. - Inbound messages:
incoming-requests,sms.ts(the handle),held-messages,reassembly,concat,udh,message-body. - Receipts:
dlr,dlr-merger,sms-id. - Codec:
pdu,pdu-framer,pdu-refusal,retained-pdu, anddefs/*as pure spec tables. - Text:
message.ts(encode, split,smppTime) anddefs/encodings. - Plumbing:
result,log,error-from,uuid,expiring-groups.
Names that do not give their purpose:
drain.tsretained-pdu.tsexpiring-groups.tserror-from.tslink-lifevslink-timersoutgoing-requestsvspending-requestsvssend-window: three names for "requests we sent"held-messagesvsreassembly: 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 plusdefs/(7). This is the worst level. Nothing in the layout groups the 10 areas, so only filenames and theAGENTS.mdlist 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 secondReconnectLoop.
defs/: 7. Fine.
4. Names
One name over two concepts:
- "unanswered" means inbound messages the application has not answered (
drain.ts:52messagesUnanswered, 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", andoutgoing-requests.ts:119const held = await waitForLink(). answeredincreateSms(sms.ts:81) is a mutable{ smsId }box, whilehandlers.answeredis the release callback. They are two things on adjacent lines.
Names that mislead:
EncodingName 'ASCII'is GSM 03.38.drain.tshouses the generalIdleWaiters.defs/houses the codec.ExpiringGroupsexpires nothing itself. Its own doc atexpiring-groups.ts:19says owners sweep and onlyweigh()evicts.session-options.ts:63documentsshutdownTimeoutas "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:5backoffDefaults, andreassembly.tsdefaultMaxOctets. The last duplicatesdefaults.maxHeldOctets(same 64 MiB). - Two near-identical collectors:
send-sms.ts:274collectSentandsms.ts:188collectReceipt. - Five concat names:
Concat,ConcatInfo,concatOf,concatInfo,ConcatReference, spread overconcat.tsandudh.ts.
5. What I would restructure, ranked
- Group
src/into about five directories:link/,outbound/,inbound/,codec/,receipts/. The seams already exist in the imports; only the layout hides them. - Give defaults one home: a single
defaultsmodule, with the client/server differences expressed as named overrides. - Split "unanswered" into two words, for example "unreleased" for app-side messages and "unanswered" for peer-side requests. Move
IdleWaitersout ofdrain.ts. - Put UDH read and write in one file, together with
ConcatReference, and movedecodeSegmentsnext tomessageOctets. - Rename
defs/types.tsto what it is, the wire field codec, or move it besidepdu.ts.
What the structure gets right:
LinkLife.transition()is one explicit table returning effects, andSession.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, andreport()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
sendRespdone,HeldMessages.idleshould settle. Release happens atheld-messages.ts:170viasms.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 thatrequestPastDrainlet through, or a heartbeatenquire_linkthe peer is not answering. - Each such request is bounded by
responseTimeout(30 s), which is longer thanshutdownTimeout(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 vialistenerCount('sms')atkeep()(held-messages.ts:154) and decremented throughcaptureRejectionsrouted fromsession.ts:97. AWeakMapkeyed on the handle's identity, and a back-reference to the half-constructedSession(session.ts:137).- The link-generation checks: repeated in
sms.ts(lostLink),incoming-requests.ts:90/97andheld-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), betweenwindow.acquireandattempt. That place is clean and local. - A custom alphabet would instead ripple through the closed
EncodingNameunion:message.ts:14segmentUnits,dataCodingByEncoding, and thesend-smschecks.
7. Hardest places, ranked
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 theSessionemitter.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 insiderequestPastDrain, and theisStopping() && canCarry()gate needs a second read.src/link-life.ts:74,LinkLife.transition, withlose:148 andend:159. Astoppingflag orthogonal to the phase,'bound'while stopping turning into a loss, and effect order that matters inSession.run(session.ts:269, wheredropLinkclears four stores).src/drain.ts:70/96,answeringBudgetanddrain:0means forever except for the messages half, plus asetImmediateturn whose job is to catch a receipt issued right after the last answer.src/reassembly.ts:188,Reassembler.trim, withcollect:111. The eviction arithmetic (parts.size - 1when the current group is itself evicted) is correct but has to be derived.src/expiring-groups.ts:19/70,ExpiringGroups.weigh: it evicts,setdoes not, and owners must sweep. Its callers' correctness depends on remembering which.src/pdu.ts:84/113,resolveShortMessage/resolveBody: which field thedata_codingdescribes, and when detection may overwrite it.src/client.ts:263/286,initialAttempts/keepTrying: a secondReconnectLoop, with a freshSessionper attempt, forfromStart.
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, andreport()logs which half stalled. The flat 37-filesrc/and the falseshutdownTimeoutcomment keep it from 8. - Locality 6: between "honest middle" and "predictable".
HeldMessagesandIncomingRequestscall back intoSession(emit,sendReturn,close), the link-generation invariant is copied into three places,Session.runeffect 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.tsanddefs/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