Bounding walks by crossings, and flagging what was cut
When an exact walk across programs costs too much, stop it after a fixed number of program crossings, keep what it found, and mark every answer the stop cut directly.
Where this shows up in reverse engineering
Cobble's last dataflow stage, cobble-dataflow-combine, answers one question for a CICS corpus: where can each dynamic jump go? EXEC CICS XCTL PROGRAM(WS-NEXT-PGM) names its target through a field, and that field's bytes may have come from another program. The combine walks those bytes backwards across programs until it finds literals.
- On a large CICS corpus (about 3,800 programs and 19,000 jumps) the earlier fixes made each walk cheap: a memo over strongly connected components (explainer 10), a per-program memo, an edge index, and places told apart by their bytes alone. Even so, one cyclic group of walks across links grew past a million places on the stack.
- The goal was narrow. Dynamic targets on that corpus come in three shapes: a literal
VALUEin working storage, aMOVE 'LITERAL' TO WS-FIELD, and (about 5%) a field a program reads from itsDFHCOMMAREAand then XCTLs to. These need 0, 0 and 1 program crossings. An answer that walks a few crossings and says honestly where it stopped covers all three.
The problem
The walk's unit is a place: a program, a statement in it, and a byte range. A statement reached under many different byte ranges is many places. Where programs hand areas to each other in a ring, every program in the ring can reach every other under many ranges, so one cyclic group holds the product of the two. On the large corpus that group grew past a million places.
The memo already answers each place once per round. The remaining cost is the number of places, which is a property of the corpus. No exact fix could be guaranteed faster without building it and measuring it on the corpus. What was needed was a walk whose cost has a known bound, and whose answers say when they may be incomplete.
The intuition
Inside one program a walk is cheap: the per-program memo answers each position once for the whole run. The expensive part is leaving the program. There are three ways to leave, and each is a crossing:
- RECEIVED — the bytes came in through the linkage section, so go to every caller that jumped here and look at what it handed over.
- RETURNED — the bytes were written back by a program this one LINKed or CALLed, so go to that callee's exits (a read-back).
- RETRIEVED — the bytes came from
EXEC CICS RETRIEVE, so go to every program that STARTed this transaction.
Give each place a counter: how many crossings it took to get here. When a place at the limit wants to cross again, it does not. It keeps the values it found in its own program, drops the crossings, and adds the reason hop-limit to what it reports. That reason travels back to the jump that started the walk, the same way every other reason in a value set does.
The cost is now bounded by the programs within N crossings of the jump, not by the size of the corpus. The answer is a subset of the exact answer. A target whose own walk was cut says so; a target shortened only because a cut elsewhere hid a link does not (see what goes wrong).
A second, separate switch, --skip-readbacks, drops one kind of crossing altogether: RETURNED stops are not followed, and the bytes report readback-skipped.
The example
Three programs in a chain. PROGC stamps 'PROGQ' into the first eight bytes of its commarea and XCTLs to PROGP. PROGP and PROGT have the same shape: each picks its next program either from a literal or from the first eight bytes of the commarea it received, and hands that commarea on. Neither PROGQ nor PROGM is in the analysed set.
IDENTIFICATION DIVISION.
PROGRAM-ID. PROGC.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-AREA.
05 WS-FROM PIC X(8).
05 WS-REST PIC X(12).
PROCEDURE DIVISION.
MOVE 'PROGQ' TO WS-FROM.
EXEC CICS XCTL PROGRAM('PROGP')
COMMAREA(WS-AREA) LENGTH(20) END-EXEC.
IDENTIFICATION DIVISION.
PROGRAM-ID. PROGP.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 WS-NEXT PIC X(8).
LINKAGE SECTION.
01 DFHCOMMAREA.
05 LK-FROM PIC X(8).
05 LK-REST PIC X(12).
PROCEDURE DIVISION.
IF EIBCALEN = 0
MOVE 'PROGT' TO WS-NEXT
ELSE
MOVE LK-FROM TO WS-NEXT
END-IF.
EXEC CICS XCTL PROGRAM(WS-NEXT)
COMMAREA(DFHCOMMAREA) LENGTH(20) END-EXEC.
IDENTIFICATION DIVISION.
PROGRAM-ID. PROGT.
* Same as PROGP, with 'PROGM' in place of 'PROGT'.
Run to the end, the combine knows two edges: PROGC → PROGP (a literal) and PROGP → PROGT (from PROGP's own MOVE 'PROGT', so it is found at every limit). The question is the target of PROGT's XCTL. Exactly, it is PROGM or PROGQ: PROGQ travels from PROGC through PROGP's commarea untouched and into PROGT.
Step by step
The walk from PROGT's XCTL meets three places. Each row is a place, what its own program gives, and where it would cross next. The last column is the place's hop count.
| place | found locally | would cross to | hops |
|---|---|---|---|
| PROGT, XCTL, WS-NEXT bytes 0–8 | {PROGM} | RECEIVED: caller PROGP, at its XCTL, commarea bytes 0–8 | 0 |
| PROGP, XCTL, DFHCOMMAREA bytes 0–8 | {} | RECEIVED: caller PROGC, at its XCTL, WS-AREA bytes 0–8 | 1 |
| PROGC, XCTL, WS-AREA bytes 0–8 | {PROGQ} | nothing | 2 |
The second place is PROGP's commarea at the moment PROGP hands it on. PROGP never writes LK-FROM, so the bytes there are what PROGP received, and the walk must cross again.
The target of PROGT at four limits (4 runs)
-
--max-hops 0. The first place has hops 0, which is the limit, and it has a crossing. It keepsPROGM, drops the crossing to PROGP, and addshop-limit. The walk ends there.place hops outcome PROGT, XCTL, 0–8 0 cut: {PROGM} + hop-limit candidates: PROGMreasons: hop-limit -
--max-hops 1. The first place has hops 0, below the limit, so it crosses: PROGP's place gets hops 1. That place is at the limit and has a crossing, so it keeps what it found locally — nothing — and addshop-limit. The extra hop bought nothing: PROGP only hands the bytes along.place hops outcome PROGT, XCTL, 0–8 0 {PROGM}, crosses PROGP, XCTL, 0–8 1 cut: {} + hop-limit candidates: PROGMreasons: hop-limit -
--max-hops 2. PROGP's place has hops 1, below the limit, so it crosses: PROGC's place gets hops 2. That place is at the limit but has no crossing —PROGQis a literal in PROGC — so nothing is cut and no reason is added.place hops outcome PROGT, XCTL, 0–8 0 {PROGM}, crosses PROGP, XCTL, 0–8 1 {}, crosses PROGC, XCTL, 0–8 2 {PROGQ}, nothing to cross candidates: PROGM, PROGQreasons: none -
No limit. Hops are not counted: every place carries 0. The same three places are expanded the same way, and the answer is the same as at 2. The row at 2 had no
hop-limit, which is what promised this.place hops outcome PROGT, XCTL, 0–8 0 {PROGM}, crosses PROGP, XCTL, 0–8 0 {}, crosses PROGC, XCTL, 0–8 0 {PROGQ}, nothing to cross candidates: PROGM, PROGQreasons: none
PROGP's own target needs one crossing fewer: its walk starts at its XCTL with {PROGT} found locally and reaches PROGC at hops 1. These are the rows the test asserts:
| --max-hops | target of PROGT | target of PROGP |
|---|---|---|
| 0 | PROGM · hop-limit | PROGT · hop-limit |
| 1 | PROGM · hop-limit | PROGQ, PROGT |
| 2 | PROGM, PROGQ | PROGQ, PROGT |
| no limit | PROGM, PROGQ | PROGQ, PROGT |
Read the first two rows for PROGT together: the answer at 0 and at 1 is the same, yet both still carry hop-limit, and at 2 it grows. That is why the reason matters more than the comparison.
The rule
expand(place) = local values, plus the places its stops cross to
if it would cross and hops ≥ N: keep local values, cross nowhere, add hop-limit
otherwise: each place crossed to gets hops + 1 (hops stays 0 when there is no limit)
--skip-readbacks: a RETURNED stop crosses nowhere and adds readback-skipped
answer(jump) = join of every place reached from (program, jump, target bytes, 0)
Why hops are counted only when a limit is set: the hop count is part of the place. With no limit, a cycle across programs would come back to the same program, statement and bytes with a larger count — a new place on every lap, and the walk would never end. With the count held at 0, the lap returns to a place already seen and the memo closes the cycle. With a limit N, a place can appear under at most N + 1 counts, so laps run out.
Why a target without hop-limit is exact: expansion depends on the hop count only through the cut. If no place reached from a jump was cut, each was expanded exactly as it would be with no limit, so the walk reaches the same programs, statements and bytes, and joins the same values and reasons. (This holds within a round, given the same edges; see below.)
What goes wrong
- Cut targets are subsets. A
hop-limitrow keeps every value it found, and they are all real, but there may be more. It is never silent: the reason is on the row intargets.jsonl. - Choosing N is empirical. Raise N until
targets.jsonlandlinks.jsonlstop changing between N and N + 1. Rows carryinghop-limitcan change, and so can rows that depend on a link a cut hid. On the large corpus, 3 hops took about 1 s, 4 about 10 s, 5 about a minute; the targets at 3 and 4 were identical, but 3 targets still carriedhop-limit, so 5 was chosen. On CardDemo, the targets were identical with no limit, with--max-hops 1, and with both flags. - Identical at N and N + 1 is not proof. The example shows it: PROGT's target is the same at 0 and 1 and grows at 2, because the middle program only relays bytes. Every relay program in a chain is one more crossing that finds nothing. The
hop-limitreason is the honest signal; the comparison is a heuristic. - Rounds couple targets. The combine runs in rounds: a target's values become new edges, and edges feed other walks. A cut that hides a program name hides the edge to that program too, and a walk into that program then sees fewer callers without itself being cut. This follows from reading the code; it was not reproduced. The cut target that hid the name does carry
hop-limit, so the trail starts at a flagged row. - Cost is per program, not per statement. N bounds how many programs a walk enters, not how big they are. Under a limit a place can also exist under up to N + 1 hop counts, so the memo may hold that many copies of it.
--skip-readbacksloses a whole shape. A program that LINKs to a routine which writes the next program's name into the commarea, and then XCTLs to that name, resolves to nothing with the flag set: the row saysreadback-skippedand has no candidate.
In cobble
The combine (cobble-dataflow-combine) applies the limit when --max-hops is set; with no limit, a walk follows links as far as they go. A walk cut at the limit carries the reason hop-limit. --skip-readbacks leaves RETURNED stops unfollowed, and the bytes report readback-skipped. Each place is answered once per round, as memoised reachability explains. Both flags are in the field dataflow reference.