redeploy-fleetd.sh: the 67-test suite covers the functions and barely touches the main flow #555

Closed
opened 2026-09-12 10:07:26 +02:00 by ltms · 2 comments
Owner

Measured on main at 93a9ed3, after #548 merged.

The shape

$ grep -c '^test_[a-z_]*()' scripts/test-redeploy-fleetd.sh    -> 67
$ grep -cE '^test_[a-z_]+$'  scripts/test-redeploy-fleetd.sh   -> 67

67 defined, 67 invoked, no orphans. The coverage is genuinely good — but it is coverage of the helper functions. jar_id, detect_supervisor, refuse_drain_gate, report_shutdown_drain, swap_if_built, stage_built_jar, wait_for_daemon_exit, scan_uncaught_exceptions are each tested hard, several with their own mutation checks.

Exactly three tests reach into the main flow, and all three do it the same way — by grepping the script's source for a call site:

test_refuse_drain_gate_call_site_present
test_report_shutdown_drain_call_site_present
test_swap_ordered_after_wait_and_before_start

A call-site grep proves the line exists. It proves nothing about the guard around it. A function can be perfect, its call site present and correctly ordered, and the if that decides whether to reach it can be inverted with every one of the 67 tests still green.

The decisions with no test at all

Each is a branch in the main flow (not inside any function), so no existing test can reach it.

# decision what an inverted guard does
1 if [ "$CHECK_ONLY" = 1 ] (:846) --check performs a real redeploy. This is the worst one: --check is documented as read-only and the skill tells the lead to run it first.
2 if [ -n "$OLD_PID" ] && [ "$ASSUME_YES" = 0 ] (:883) — the drain-gate entry a bare run skips the drain prompt entirely, or --yes starts prompting. refuse_drain_gate itself is tested four ways and its call site is pinned; nothing pins that this if still guards it.
3 if [ "$reply" != "yes" ] (:892) typing yes aborts; typing anything else proceeds.
4 case "$SUPERVISOR_KIND" — the report dispatch (:784) reports the wrong supervisor, and skips check_log_path_matches_plist, whose own comment says every check after it is worthless if it does not run.
5 case "$SUPERVISOR_KIND" — the stop dispatch (:917) kills a supervised daemon instead of launchctl unload / systemctl --user stop, so the supervisor restarts the OLD jar — the exact CB-594 / #492 failure both branches were written to prevent.
6 case "$SUPERVISOR_KIND" — the start dispatch (:992) starts the daemon by the wrong mechanism; for launchd the branch also carries the retry that stops the agent being left stopped-and-disabled.
7 the HEALTH_BODY poll loop and if [ -z "$HEALTH_BODY" ] (:1049-1068) a dead daemon reports /healthz 200, or a live one reports failure.
8 HAD_OLD_PID=0; [ -n "$OLD_PID" ] && HAD_OLD_PID=1 (:1099) feeds report_shutdown_drain's n/a decision. All four outcomes are tested; nothing tests that the input is computed right.

One suspicion I measured and killed — do not re-derive it

Item 8's idiom looks like a set -e hazard: when OLD_PID is empty the && list's status is 1, under set -euo pipefail, after the daemon has already been swapped and restarted. That would be the same severity as #552. It is not a bug:

bash 3.2.57 (/bin/bash)          -> SURVIVED HAD_OLD_PID=0   rc=0
bash 5.3.9  (homebrew)           -> SURVIVED HAD_OLD_PID=0   rc=0

[ -n "$OLD_PID" ] is not the command following the final &&, so set -e exempts it. The idiom is safe at both shell versions this script must run under. It still has no test.

Why this is worth fixing rather than noting

The suite's own strongest tests are mutation checks (test_recovery_requirement_mutation_is_caught, test_shared_counter_mutation_is_caught, test_unattributable_quiet_mutation_is_caught). Those exist because the author already knew a passing assertion is not a proof. The same standard has simply never been applied to the main flow, because the main flow is not callable — sourcing the script runs it.

That is the actual blocker, and it is the thing to solve first: there is no seam that lets a test execute one main-flow branch. Until there is, every new guard added to the main flow arrives untestable by construction, which is how the list above reached eight.

Suggested approach

Not prescriptive — whoever takes this should measure before committing to one.

  1. Give the script a REDEPLOY_SOURCED_FOR_TEST guard (or return when sourced) so the test harness can source it for its functions and call small main-flow steps, the way the function tests already source it today.
  2. Then lift each decision above into a named predicate function beside the ones that already exist — should_stop_here, health_is_up, drain_gate_required — following the swap_if_built / refuse_drain_gate pattern this script already uses: decision and action together in one function the main flow calls unconditionally, so there is no guard left in the main flow to invert.
  3. Each lifted predicate gets a test and a mutation check, same as the three that already have one.

Acceptance: pick the shape, not the eight sites. A test that greps for main-flow if/case keywords outside any function and fails when one appears without a matching predicate test — otherwise a ninth arrives next month. That is the lesson #545 already paid for with mktemp -t: six named lines would have missed the seventh.

Related

  • #552 — a mktemp abort after the restart, in the same file. Both touch scripts/redeploy-fleetd.sh; sequence them.
  • #550 — jar_id/shasum on Linux, same file, in flight now.
  • #504 items 2, 3, 4 — running_pid, the three zsh -lc probes, curl … || echo 000 are the same untested-main-flow family.
  • #528 item 2 — wait_for_daemon_exit's call site is "partially pinned, not audited", which is item-2's shape one function over.
Measured on `main` at `93a9ed3`, after #548 merged. ## The shape ``` $ grep -c '^test_[a-z_]*()' scripts/test-redeploy-fleetd.sh -> 67 $ grep -cE '^test_[a-z_]+$' scripts/test-redeploy-fleetd.sh -> 67 ``` 67 defined, 67 invoked, no orphans. The coverage is genuinely good — but it is coverage **of the helper functions**. `jar_id`, `detect_supervisor`, `refuse_drain_gate`, `report_shutdown_drain`, `swap_if_built`, `stage_built_jar`, `wait_for_daemon_exit`, `scan_uncaught_exceptions` are each tested hard, several with their own mutation checks. Exactly three tests reach into the main flow, and all three do it the same way — by grepping the script's source for a call site: ``` test_refuse_drain_gate_call_site_present test_report_shutdown_drain_call_site_present test_swap_ordered_after_wait_and_before_start ``` **A call-site grep proves the line exists. It proves nothing about the guard around it.** A function can be perfect, its call site present and correctly ordered, and the `if` that decides whether to reach it can be inverted with every one of the 67 tests still green. ## The decisions with no test at all Each is a branch in the main flow (not inside any function), so no existing test can reach it. | # | decision | what an inverted guard does | |---|---|---| | 1 | `if [ "$CHECK_ONLY" = 1 ]` (`:846`) | **`--check` performs a real redeploy.** This is the worst one: `--check` is documented as read-only and the skill tells the lead to run it first. | | 2 | `if [ -n "$OLD_PID" ] && [ "$ASSUME_YES" = 0 ]` (`:883`) — the drain-gate **entry** | a bare run skips the drain prompt entirely, or `--yes` starts prompting. `refuse_drain_gate` itself is tested four ways and its call site is pinned; nothing pins that this `if` still guards it. | | 3 | `if [ "$reply" != "yes" ]` (`:892`) | typing `yes` aborts; typing anything else proceeds. | | 4 | `case "$SUPERVISOR_KIND"` — the **report** dispatch (`:784`) | reports the wrong supervisor, and skips `check_log_path_matches_plist`, whose own comment says every check after it is worthless if it does not run. | | 5 | `case "$SUPERVISOR_KIND"` — the **stop** dispatch (`:917`) | kills a supervised daemon instead of `launchctl unload` / `systemctl --user stop`, so the supervisor restarts the OLD jar — the exact CB-594 / #492 failure both branches were written to prevent. | | 6 | `case "$SUPERVISOR_KIND"` — the **start** dispatch (`:992`) | starts the daemon by the wrong mechanism; for `launchd` the branch also carries the retry that stops the agent being left stopped-and-disabled. | | 7 | the `HEALTH_BODY` poll loop and `if [ -z "$HEALTH_BODY" ]` (`:1049-1068`) | a dead daemon reports `/healthz 200`, or a live one reports failure. | | 8 | `HAD_OLD_PID=0; [ -n "$OLD_PID" ] && HAD_OLD_PID=1` (`:1099`) | feeds `report_shutdown_drain`'s `n/a` decision. All four outcomes are tested; nothing tests that the input is computed right. | ## One suspicion I measured and killed — do not re-derive it Item 8's idiom looks like a `set -e` hazard: when `OLD_PID` is empty the `&&` list's status is 1, under `set -euo pipefail`, *after* the daemon has already been swapped and restarted. That would be the same severity as #552. It is not a bug: ``` bash 3.2.57 (/bin/bash) -> SURVIVED HAD_OLD_PID=0 rc=0 bash 5.3.9 (homebrew) -> SURVIVED HAD_OLD_PID=0 rc=0 ``` `[ -n "$OLD_PID" ]` is not the command following the final `&&`, so `set -e` exempts it. The idiom is safe at both shell versions this script must run under. It still has no test. ## Why this is worth fixing rather than noting The suite's own strongest tests are mutation checks (`test_recovery_requirement_mutation_is_caught`, `test_shared_counter_mutation_is_caught`, `test_unattributable_quiet_mutation_is_caught`). Those exist because the author already knew a passing assertion is not a proof. The same standard has simply never been applied to the main flow, because the main flow is not callable — sourcing the script runs it. That is the actual blocker, and it is the thing to solve first: **there is no seam that lets a test execute one main-flow branch.** Until there is, every new guard added to the main flow arrives untestable by construction, which is how the list above reached eight. ## Suggested approach Not prescriptive — whoever takes this should measure before committing to one. 1. Give the script a `REDEPLOY_SOURCED_FOR_TEST` guard (or `return` when sourced) so the test harness can source it for its functions **and** call small main-flow steps, the way the function tests already source it today. 2. Then lift each decision above into a named predicate function beside the ones that already exist — `should_stop_here`, `health_is_up`, `drain_gate_required` — following the `swap_if_built` / `refuse_drain_gate` pattern this script already uses: decision and action together in one function the main flow calls unconditionally, so there is no guard left in the main flow to invert. 3. Each lifted predicate gets a test **and** a mutation check, same as the three that already have one. Acceptance: pick the **shape**, not the eight sites. A test that greps for main-flow `if`/`case` keywords outside any function and fails when one appears without a matching predicate test — otherwise a ninth arrives next month. That is the lesson #545 already paid for with `mktemp -t`: six named lines would have missed the seventh. ## Related - #552 — a `mktemp` abort *after* the restart, in the same file. Both touch `scripts/redeploy-fleetd.sh`; sequence them. - #550 — `jar_id`/`shasum` on Linux, same file, in flight now. - #504 items 2, 3, 4 — `running_pid`, the three `zsh -lc` probes, `curl … || echo 000` are the same untested-main-flow family. - #528 item 2 — `wait_for_daemon_exit`'s call site is "partially pinned, not audited", which is item-2's shape one function over.
Author
Owner

Rework on PR #565 — one demonstrated hole in the new structural guard

The refactor itself verifies clean. One gap in test_no_untested_main_flow_conditionals, found by a reviewer and then reproduced by me with a control.

What I verified first (all clean)

Merged into origin/main (f4f5f31) → 84212b8.

  • suite exit 0 on both bash 5.3.9 and /bin/bash 3.2.57 (macOS system bash)
  • tests 79 → 118, and every defined test is actually invoked — comm both directions on the sorted definition and call-site name sets is empty, so there is no defined-but-never-called test
  • redeploy-fleetd.sh sha 4ffacc51…61e8d9 matches the sha you reported
  • two mutations you did not run, both killed:
    • :1009 [ "$code" = "503" ] → != (anchor 1 → 0) ⇒ FAIL: report_health did not report the 503-degraded case
    • :1014 die → warn on the never-answered branch (anchor 1 → 0) ⇒ FAIL: report_health must die when the body is empty and the code is not 503
    • So the 503 constraint is genuinely pinned in both directions. That was the regression I was most worried about.
  • behaviour equivalence, measured rather than argued: --check run on the pre-refactor and post-refactor scripts produces 20 lines each, identical apart from the commit sha and the worktree path, same exit code. That is the strongest evidence available that the 8 lifted decisions did not change observable behaviour.
  • the five mktemp: cannot create temp file lines in the suite output are expected — stub mktemp binaries the tests install on PATH. origin/main emits exactly five too. Not a defect; I checked before assuming.

The hole: a conditional wrapped in a function defined below the sourcing boundary

The guard skips anything inside a function body. But a function defined after the SOURCED guard (redeploy-fleetd.sh:1032) can never be sourced by the suite, so it is untestable by construction — while still reading to the guard as "inside a function, therefore fine".

Reproduced with a control so the two states are distinguishable:

=== CONTROL: a BARE conditional in the main flow must be caught ===
EXIT=1
FAIL: found 1 untested main-flow if/elif/case line(s), not lifted into a tested predicate
function and not in MAIN_FLOW_ALLOWED_CONDITIONALS:
  line 1340: if [ "$MY_CONTROL_BARE" = 1 ]; then :; fi

=== CANDIDATE: the SAME conditional wrapped in a function after the boundary ===
newfunc_below_the_boundary() {
  if [ "$MY_HIDDEN_COND" = 1 ]; then :; fi
}
newfunc_below_the_boundary

EXIT=0        <- not caught

File restored to 4ffacc51…61e8d9 after each.

The control matters: it proves the guard is not simply inert. It catches the bare form at a position of my choosing and emits a precise message naming the line. The guard is real — it just has one shape it cannot see.

Independent confirmation of the same hole, from the reviewer, including the part I did not measure: the allowlist holds 18 entries against 19 candidate lines the scan actually finds, so it is not vacuous; and today no function definitions exist after the guard line at all, so boundary tracking is currently inert rather than wrong. The hole is latent, not live. It becomes live the first time someone adds a function down there — which is exactly the edit this guard exists to catch.

Acceptance for the rework

  1. Make the guard also fail on any function definition after the SOURCED guard line. Such a function cannot be sourced, so it cannot be tested, and the existing check treats it as if it were. One added assertion in the same test closes the shape completely — it does not need a second scan.
  2. Prove it red: add newfunc_below_the_boundary() { if [ "$X" = 1 ]; then :; fi } below the boundary, run the suite, and paste the exact failure text. Then restore and confirm the sha returns to 4ffacc5185807d39720a3484d85b922413806eb5347318265bd8897dfd61e8d9.
  3. Keep the existing control green: the bare-conditional case must still be caught, with its message unchanged. Show both.
  4. Re-run the full suite under both bash and /bin/bash, reporting the exit code next to each result.

Nothing else changes. The 8 lifted decisions, their mutation proofs and the allowlist all stand.

One correction to your report's numbers

You reported "236 test_ function definitions … up from 67". Measured on your branch: 118 definitions and 118 invocations; origin/main has 79 of each. The 236 is the two sets added together — the pattern counted definitions and call sites both. The direction was right and it does not affect the work, but the count is worth correcting since it is the kind of number that gets quoted later.

## Rework on PR #565 — one demonstrated hole in the new structural guard The refactor itself verifies clean. One gap in `test_no_untested_main_flow_conditionals`, found by a reviewer and then reproduced by me with a control. ### What I verified first (all clean) Merged into `origin/main` (`f4f5f31`) → `84212b8`. - suite **exit 0** on both `bash 5.3.9` and `/bin/bash 3.2.57` (macOS system bash) - tests **79 → 118**, and every defined test is actually invoked — `comm` both directions on the sorted definition and call-site name sets is empty, so there is no defined-but-never-called test - `redeploy-fleetd.sh` sha `4ffacc51…61e8d9` matches the sha you reported - **two mutations you did not run, both killed:** - `:1009` `[ "$code" = "503" ]` → `!=` (anchor 1 → 0) ⇒ `FAIL: report_health did not report the 503-degraded case` - `:1014` `die` → `warn` on the never-answered branch (anchor 1 → 0) ⇒ `FAIL: report_health must die when the body is empty and the code is not 503` - So the 503 constraint is genuinely pinned in both directions. That was the regression I was most worried about. - **behaviour equivalence, measured rather than argued:** `--check` run on the pre-refactor and post-refactor scripts produces **20 lines each, identical apart from the commit sha and the worktree path**, same exit code. That is the strongest evidence available that the 8 lifted decisions did not change observable behaviour. - the five `mktemp: cannot create temp file` lines in the suite output are **expected** — stub `mktemp` binaries the tests install on PATH. `origin/main` emits exactly five too. Not a defect; I checked before assuming. ### The hole: a conditional wrapped in a function defined below the sourcing boundary The guard skips anything inside a function body. But a function defined **after** the `SOURCED` guard (`redeploy-fleetd.sh:1032`) can never be sourced by the suite, so it is untestable by construction — while still reading to the guard as "inside a function, therefore fine". Reproduced with a control so the two states are distinguishable: ``` === CONTROL: a BARE conditional in the main flow must be caught === EXIT=1 FAIL: found 1 untested main-flow if/elif/case line(s), not lifted into a tested predicate function and not in MAIN_FLOW_ALLOWED_CONDITIONALS: line 1340: if [ "$MY_CONTROL_BARE" = 1 ]; then :; fi === CANDIDATE: the SAME conditional wrapped in a function after the boundary === newfunc_below_the_boundary() { if [ "$MY_HIDDEN_COND" = 1 ]; then :; fi } newfunc_below_the_boundary EXIT=0 <- not caught ``` File restored to `4ffacc51…61e8d9` after each. The control matters: it proves the guard is not simply inert. It catches the bare form at a position of my choosing and emits a precise message naming the line. The guard is real — it just has one shape it cannot see. Independent confirmation of the same hole, from the reviewer, including the part I did not measure: the allowlist holds **18 entries** against **19 candidate lines** the scan actually finds, so it is not vacuous; and today **no function definitions exist after the guard line at all**, so boundary tracking is currently inert rather than wrong. The hole is latent, not live. It becomes live the first time someone adds a function down there — which is exactly the edit this guard exists to catch. ### Acceptance for the rework 1. **Make the guard also fail on any function definition after the `SOURCED` guard line.** Such a function cannot be sourced, so it cannot be tested, and the existing check treats it as if it were. One added assertion in the same test closes the shape completely — it does not need a second scan. 2. **Prove it red**: add `newfunc_below_the_boundary() { if [ "$X" = 1 ]; then :; fi }` below the boundary, run the suite, and paste the exact failure text. Then restore and confirm the sha returns to `4ffacc5185807d39720a3484d85b922413806eb5347318265bd8897dfd61e8d9`. 3. **Keep the existing control green**: the bare-conditional case must still be caught, with its message unchanged. Show both. 4. Re-run the full suite under **both** `bash` and `/bin/bash`, reporting the exit code next to each result. Nothing else changes. The 8 lifted decisions, their mutation proofs and the allowlist all stand. ### One correction to your report's numbers You reported "236 test_ function definitions … up from 67". Measured on your branch: **118 definitions and 118 invocations**; `origin/main` has **79** of each. The 236 is the two sets added together — the pattern counted definitions and call sites both. The direction was right and it does not affect the work, but the count is worth correcting since it is the kind of number that gets quoted later.
ltms closed this issue 2026-09-12 12:19:48 +02:00
Author
Owner

Merged as ba2f4d1 (PR #565). Closing.

What the rework had to fix

The first pass lifted 8 main-flow decisions into predicate and dispatch functions, and added test_no_untested_main_flow_conditionals to stop new bare conditionals appearing. That guard had a hole: it tracks whether a line sits inside a function, and waves through anything that does. So a conditional wrapped in a function defined below the SOURCED guard was invisible.

The hole matters because such a function can never be sourced by the suite — sourcing returns at the SOURCED guard, before any code below it runs. So its body is untestable by construction, and the old guard read it as "inside a function, therefore fine". I reproduced this with a control before filing it.

The fix emits a FUNC record for every function opened after the guard line, and treats any FUNC record as a violation on its own — independent of what the body contains and of the allowlist.

Verification (lead, on a tree with main merged in, and again on real main after the merge)

scripts/redeploy-fleetd.sh at its pristine sha 4ffacc5185807d39720a3484d85b922413806eb5347318265bd8897dfd61e8d9:

shell exit lines matching ^FAIL:
bash 5.3.9 0 0
/bin/bash 3.2.57 (macOS system bash) 0 0

PASS: redeploy log classifier is printed unconditionally at the end of the suite, so it is not evidence. The exit code and the ^FAIL: count are.

Three mutations, each with the subject restored to its pristine sha afterwards:

1. Control — a bare conditional appended to the main flow. Must still be caught.

exit 1
  line 1341: if [ "$LEAD_PROBE_BARE" = 1 ]; then :; fi

2. Candidate — the same conditional wrapped in a function below the guard. This is the hole.

exit 1
  line 1341: function defined after the SOURCED guard (line 1038) — it cannot be
             sourced, so it cannot be tested: lead_probe_wrapped() {

3. The fix itself removed — delete the one line that emits the FUNC record, then re-run case 2. It returns to exit 0. So the new assertion is what catches it, not something else that happened to be in the way. Anchor count 1 -> 0; test-redeploy-fleetd.sh restored to 0d713a3092a0c0ea8c05663ffa0cb1595e21b9d73872e662eb99b5035fd38151.

One thing to know about the new guard's reach

The function-definition pattern only matches name() { with the brace on the same line. function name { and a brace on the next line are not matched. That direction is safe: when the pattern misses a definition, the depth tracker never enters "inside a function", so conditionals in that body are reported as bare conditionals instead. The miss produces a catch, not a hole. Worth knowing if anyone later widens the pattern and wonders why nothing changed.

Correction carried over from the worker's report

An earlier reply from the worker gave "236 test_ definitions". That number was wrong — it summed definitions and invocations. The worker flagged it themselves rather than let it stand. The measured figures are 118 test definitions and 118 invocations, with comm proving every defined test is actually invoked.

Merged as `ba2f4d1` (PR #565). Closing. ## What the rework had to fix The first pass lifted 8 main-flow decisions into predicate and dispatch functions, and added `test_no_untested_main_flow_conditionals` to stop new bare conditionals appearing. That guard had a hole: it tracks whether a line sits inside a function, and waves through anything that does. So a conditional **wrapped in a function defined below the `SOURCED` guard** was invisible. The hole matters because such a function can never be sourced by the suite — sourcing returns at the `SOURCED` guard, before any code below it runs. So its body is untestable by construction, and the old guard read it as "inside a function, therefore fine". I reproduced this with a control before filing it. The fix emits a `FUNC` record for every function opened after the guard line, and treats any `FUNC` record as a violation on its own — independent of what the body contains and of the allowlist. ## Verification (lead, on a tree with main merged in, and again on real main after the merge) `scripts/redeploy-fleetd.sh` at its pristine sha `4ffacc5185807d39720a3484d85b922413806eb5347318265bd8897dfd61e8d9`: | shell | exit | lines matching `^FAIL:` | |---|---|---| | `bash` 5.3.9 | 0 | 0 | | `/bin/bash` 3.2.57 (macOS system bash) | 0 | 0 | `PASS: redeploy log classifier` is printed **unconditionally** at the end of the suite, so it is not evidence. The exit code and the `^FAIL:` count are. Three mutations, each with the subject restored to its pristine sha afterwards: **1. Control — a bare conditional appended to the main flow. Must still be caught.** ``` exit 1 line 1341: if [ "$LEAD_PROBE_BARE" = 1 ]; then :; fi ``` **2. Candidate — the same conditional wrapped in a function below the guard. This is the hole.** ``` exit 1 line 1341: function defined after the SOURCED guard (line 1038) — it cannot be sourced, so it cannot be tested: lead_probe_wrapped() { ``` **3. The fix itself removed** — delete the one line that emits the `FUNC` record, then re-run case 2. It returns to **exit 0**. So the new assertion is what catches it, not something else that happened to be in the way. Anchor count 1 -> 0; `test-redeploy-fleetd.sh` restored to `0d713a3092a0c0ea8c05663ffa0cb1595e21b9d73872e662eb99b5035fd38151`. ## One thing to know about the new guard's reach The function-definition pattern only matches `name() {` with the brace on the same line. `function name {` and a brace on the next line are not matched. That direction is safe: when the pattern misses a definition, the depth tracker never enters "inside a function", so conditionals in that body are reported as bare conditionals instead. The miss produces a catch, not a hole. Worth knowing if anyone later widens the pattern and wonders why nothing changed. ## Correction carried over from the worker's report An earlier reply from the worker gave "236 `test_` definitions". That number was wrong — it summed definitions and invocations. The worker flagged it themselves rather than let it stand. The measured figures are **118 test definitions and 118 invocations**, with `comm` proving every defined test is actually invoked.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#555