Plan the comprehension rewrite and file its background
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
Test / lint (pull_request) Successful in 23s
Test / test (18) (pull_request) Successful in 30s
Test / test (20) (pull_request) Successful in 31s
Test / test (22) (pull_request) Successful in 31s
Test / test (24) (pull_request) Successful in 30s
Test / test (26) (pull_request) Successful in 30s
Mirror / push (push) Successful in 5s
This commit was merged in pull request #50.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user