From c73648fced05e1dd729bc1715064accce16a65f3 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 19:36:51 -0600 Subject: [PATCH 1/7] watch signal handling and termination semantics (Closes #34) - SIGINT/SIGTERM stop the loop cleanly via DispatchSourceSignal on the main queue (delivered by the watch loop's RunLoop draining) - stop reason observable in both channels: terminal {type:end,reason:count|timeout|sigint|sigterm} event in --jsonl mode, stderr summary in human mode - exit codes: 0 count, 124 timeout (GNU), 130 SIGINT, 143 SIGTERM - jsonl stream never carries non-JSON lines (encode failure now emits a WatchErrorEvent) - unbounded watch prints a one-time stderr hint suggesting --count/--timeout - tests (2 new) + smoke covers all exit paths incl. kill -INT; specs/README in lockstep - SpecSync CHG-0021 accepted; evidence rebound for shared changes (audited reopens, actor 0xLeif) --- .specsync/change-sequence.json | 4 +- .../approvals.json | 19 +++ .../change.md | 24 +++ .../context.md | 23 +++ .../deltas/aps-cli.md | 67 ++++++++ .../design.md | 17 ++ .../docs.md | 10 ++ .../plan.md | 15 ++ .../requirements.md | 10 ++ .../state.json | 45 ++++++ .../tasks.md | 11 ++ .../testing.md | 17 ++ .../verification-attempts.json | 22 +++ .../verification.json | 153 ++++++++++++++++++ README.md | 2 + Scripts/smoke.sh | 19 +++ Sources/aps/Aps.swift | 58 ++++++- Sources/aps/CLIOutput.swift | 21 +++ Sources/aps/WatchTermination.swift | 85 ++++++++++ Tests/apsTests/APSTests.swift | 29 ++++ specs/aps-cli/aps-cli.spec.md | 11 +- specs/aps-cli/requirements.md | 11 +- 22 files changed, 659 insertions(+), 14 deletions(-) create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/change.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/context.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/deltas/aps-cli.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/design.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/docs.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/plan.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/requirements.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/tasks.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/testing.md create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json create mode 100644 .specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json create mode 100644 Sources/aps/WatchTermination.swift diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index 6e9ea27..66369b1 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 22, - "id": "CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33", + "sequence": 23, + "id": "CHG-0023-watch-signal-handling-and-termination-semantics-issue-34", "acknowledged_collisions": [] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json new file mode 100644 index 0000000..416afc6 --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json @@ -0,0 +1,19 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "0xLeif", + "timestamp": 1784424341, + "digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784424361, + "digest": "f13f1d8e9ddb217cadc43217caf93e5be51d1cedcd341e3be4fd3a095b4b0876", + "note": null + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/change.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/change.md new file mode 100644 index 0000000..6a60353 --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/change.md @@ -0,0 +1,24 @@ +--- +id: CHG-0023-watch-signal-handling-and-termination-semantics-issue-34 +state: accepted +type: feature +base_commit: c4ecd60b2a46bca4780f1b4af79a1d495d646287 +--- + +# Watch signal handling and termination semantics (issue 34) + +## Intent + +watch signal handling and termination semantics (issue 34) + +## Affected Canonical Specs + +- `aps-cli` + +## Acceptance Criteria + +- watch stops cleanly for count (exit 0), timeout (exit 124), SIGINT (exit 130), and SIGTERM (exit 143); the stop reason appears as a terminal {type:end,reason:...} event in --jsonl mode or a stderr line in human mode; the jsonl stream never contains non-JSON lines; an unbounded watch prints a one-time stderr hint; smoke covers all exit paths including kill -INT; specs updated. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/context.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/context.md new file mode 100644 index 0000000..2c58977 --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/context.md @@ -0,0 +1,23 @@ +--- +change: CHG-0023-watch-signal-handling-and-termination-semantics-issue-34 +artifact: context +--- + +# Context + +Issue #34 (design round 2, decision: both channels). watch ran forever +with no signal handling, bounded exits always returned 0 whether count or +timeout stopped the loop, and the jsonl fallback could inject a raw +non-JSON line into the stream. + +## Decisions + +- Exit codes: 0 count (request satisfied), 124 timeout (GNU convention), + 128+signal (130 SIGINT, 143 SIGTERM). +- Reason is observable in both channels: terminal + `{"type":"end","reason":...}` event in `--jsonl` mode, stderr summary in + human mode. +- The jsonl stream never contains non-JSON lines: event-encode failures + now emit a WatchErrorEvent instead of the raw value line. +- Unbounded watch prints a one-time stderr hint suggesting + `--count` / `--timeout`. diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/deltas/aps-cli.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/deltas/aps-cli.md new file mode 100644 index 0000000..ed01df0 --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/deltas/aps-cli.md @@ -0,0 +1,67 @@ +# aps-cli watch termination semantics + +## MODIFIED + +### REQUIREMENT REQ-aps-cli-012 + +`watch` SHALL support `--count`, `--timeout`, and `--jsonl`, and SHALL handle SIGINT/SIGTERM with observable termination semantics. + +Acceptance Criteria +- `--count` stops after that many printed values including the initial value (exit 0). +- `--timeout` stops after the given seconds (exit 124). +- SIGINT/SIGTERM stop the loop cleanly (exit 130 / 143). +- The stop reason appears as a terminal `{"type":"end","reason":...}` event in `--jsonl` mode or a stderr line in human mode. +- The `--jsonl` stream never contains non-JSON lines. +- An unbounded watch prints a one-time stderr hint suggesting `--count` / `--timeout`. + +### SPEC SECTION Public API + +| Export | Description | +|--------|-------------| +| `DemoKey` | Fixed schema enum including `profile`, `secret`, and `profileName`. | +| `ProfileDocument` | Codable `{name, version}` FileState document. | +| `name` | ProfileDocument display name field. | +| `version` | ProfileDocument integer version field. | +| `init` | ProfileDocument memberwise initializer. | +| `APSKeychain` | Well-known Keychain service/account for `secret`. | +| `service` | Reverse-DNS Keychain feature namespace (`dev.leif.aps`). | +| `account` | Keychain account id (`secret`). | +| `secretAccount` | Full account key (`dev.leif.aps/secret`). | +| `APSError` | Typed CLI/domain errors. | +| `counter` | Int key stored in AppState `State`. | +| `message` | String key stored in AppState `State`. | +| `flag` | Bool key stored in AppState `StoredState`. | +| `note` | String key stored in AppState `FileState`. | +| `profile` | ProfileDocument key stored in AppState `FileState`. | +| `secret` | String key stored in AppState `SecureState` (Keychain). | +| `profileName` | String Slice over `ProfileDocument.name`. | +| `invalidValue` | Value could not parse for the key type. | +| `encodingFailed` | UTF-8 JSON encode failure. | +| `decodingFailed` | UTF-8 JSON decode failure. | +| `persistenceFailed` | Disk/Keychain-backed key did not persist after write. | +| `keychainUnavailable` | SecureState unavailable without Apple Security. | +| `corruptState` | FileState file exists but is undecodable (torn write). | +| `corruptStateExitCode` | Exit code 65 (`EX_DATAERR`) for `corruptState`. | +| `storage` | Human storage kind (`State` / `StoredState` / `FileState` / `SecureState` / `Slice`). | +| `valueType` | Human value type (`Int` / `String` / `Bool` / `ProfileDocument`). | +| `helpSummary` | Tab-separated key/type/storage columns for `keys`. | +| `detail` | One-line description for `keys`. | +| `description` | Actionable error text for humans and ValidationError bridging. | + +### SPEC SECTION Invariants + +1. The CLI entry point runs on the real main thread so AppState + `notifyChange()` assertions hold on Linux and macOS. +2. stdout for `get` / `set` / `watch` / `reset ` is only the value line(s); + help and errors use ArgumentParser defaults. +3. `State` keys are process-local; a new process must not be expected to retain + `counter` or `message`. +4. `watch` must flush each printed value immediately when stdout is not a TTY. +5. `keys` and `--help` do not mutate application state. + +6. `watch` termination is observable in both channels: a terminal + `{"type":"end","reason":"count|timeout|sigint|sigterm"}` event in `--jsonl` + mode or a stderr line in human mode, with exit codes 0 (count), 124 + (timeout), 128+signal (130 SIGINT, 143 SIGTERM). The `--jsonl` stream never + contains non-JSON lines. An unbounded watch prints a one-time stderr hint + suggesting `--count` / `--timeout`. diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/design.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/design.md new file mode 100644 index 0000000..d880dc4 --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/design.md @@ -0,0 +1,17 @@ +--- +change: CHG-0023-watch-signal-handling-and-termination-semantics-issue-34 +artifact: design +--- + +# Design + +- New `Sources/aps/WatchTermination.swift`: `SignalBox` (lock-guarded + signal record), `installWatchSignalHandlers` (SIG_IGN plus + DispatchSourceSignal on the main queue, delivered by the watch loop's + RunLoop draining), and `StopReason` (token / exitCode / summary). +- Watch.run composes `shouldContinue` from count, deadline, and the signal + box, recording the first stop reason. After the loop: terminal + `WatchEndEvent` on stdout in `--jsonl` mode or a stderr line, then + `ExitCode(reason.exitCode)` when non-zero. +- jsonl purity: the encode-failure fallback now writes a WatchErrorEvent + with `error: "encoding_failed"`. diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/docs.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/docs.md new file mode 100644 index 0000000..ecb50c4 --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/docs.md @@ -0,0 +1,10 @@ +--- +change: CHG-0023-watch-signal-handling-and-termination-semantics-issue-34 +artifact: docs +--- + +# Docs + +README watch paragraph: exit-code table (0/124/130/143), the terminal +`{"type":"end","reason":...}` event, the jsonl purity rule, and the +unbounded hint. diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/plan.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/plan.md new file mode 100644 index 0000000..5a5fb8b --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/plan.md @@ -0,0 +1,15 @@ +--- +change: CHG-0023-watch-signal-handling-and-termination-semantics-issue-34 +artifact: plan +--- + +# Plan + +1. Add WatchTermination.swift (SignalBox, handlers, StopReason). +2. Add CLIOutput.WatchEndEvent and writeError; rework Watch.run (reason + tracking, terminal marker, exit codes, hint, jsonl purity). +3. Tests: StopReason tokens/exit codes, WatchEndEvent shape. +4. Smoke: count exit 0, timeout exit 124 with end event, SIGINT exit 130 + via kill -INT, jsonl purity check. +5. aps-cli spec (files list, termination invariant), REQ-aps-cli-012, + README. diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/requirements.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/requirements.md new file mode 100644 index 0000000..23b6bbf --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/requirements.md @@ -0,0 +1,10 @@ +--- +change: CHG-0023-watch-signal-handling-and-termination-semantics-issue-34 +artifact: requirements +--- + +# Requirements + +- REQ-aps-cli-012 extended: SIGINT/SIGTERM handling, observable stop + reason in both channels, exit codes 0/124/130/143, jsonl purity, and + the unbounded hint. diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json new file mode 100644 index 0000000..73e4498 --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "id": "CHG-0023-watch-signal-handling-and-termination-semantics-issue-34", + "slug": "watch-signal-handling-and-termination-semantics-issue-34", + "title": "Watch signal handling and termination semantics (issue 34)", + "description": "watch signal handling and termination semantics (issue 34)", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "created_at": 1784424255, + "updated_at": 1784424361, + "affected_specs": [ + "aps-cli" + ], + "affected_paths": [ + "Sources/aps/WatchTermination.swift", + "Sources/aps/Aps.swift", + "Sources/aps/CLIOutput.swift", + "Tests/apsTests/APSTests.swift", + "Scripts/smoke.sh", + "specs/aps-cli", + "README.md", + ".specsync/change-sequence.json" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "watch stops cleanly for count (exit 0), timeout (exit 124), SIGINT (exit 130), and SIGTERM (exit 143); the stop reason appears as a terminal {type:end,reason:...} event in --jsonl mode or a stderr line in human mode; the jsonl stream never contains non-JSON lines; an unbounded watch prints a one-time stderr hint; smoke covers all exit paths including kill -INT; specs updated." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs", + "design" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/tasks.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/tasks.md new file mode 100644 index 0000000..1106cbd --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/tasks.md @@ -0,0 +1,11 @@ +--- +change: CHG-0023-watch-signal-handling-and-termination-semantics-issue-34 +artifact: tasks +--- + +# Tasks + +- [x] WatchTermination.swift +- [x] WatchEndEvent + Watch.run rework +- [x] Unit tests and smoke assertions +- [x] aps-cli spec, requirements, README updates diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/testing.md b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/testing.md new file mode 100644 index 0000000..30cd50d --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/testing.md @@ -0,0 +1,17 @@ +--- +change: CHG-0023-watch-signal-handling-and-termination-semantics-issue-34 +artifact: testing +--- + +# Testing + +## Requirement evidence + +| Requirement | Evidence | +| --- | --- | +| REQ-aps-cli-012 | `testStopReasonTokensAndExitCodes`, `testWatchEndEventEncodesTerminalMarker`; smoke covers count exit 0, timeout exit 124 with end event, SIGINT exit 130 via kill -INT, and jsonl purity | + +## Suites + +- `swift test` +- `fledge lanes run verify` diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json new file mode 100644 index 0000000..366b377 --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1784424356, + "commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "4d2e9a44083c94210666bfa389388c9b32ab3bfbb98db2476ba49c918130629d", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] + } + ] +} diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json new file mode 100644 index 0000000..939c1fd --- /dev/null +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json @@ -0,0 +1,153 @@ +{ + "timestamp": 1784424356, + "commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "4d2e9a44083c94210666bfa389388c9b32ab3bfbb98db2476ba49c918130629d", + "acceptance_input_digest": "867757265c0081cdd804e8b95dc4ca37bb568595af91811d65968d72d65641ff", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "bd09a0ace8b7d37cfc0b686ab0ff2d3094fdb015cbb2cd09e6e6a5db6961439f", + "entry_digest": "e9f52413e2cae8ce5dcf4d5ebfb03b873d245731b80c59d18d3c9cc3c19c009f", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "1adbf9dab00c1129b9e50c355a9098c75ec4b534af048e8a953c9037c8e3f9cb", + "entry_digest": "bf24557a813a8fdfb925a55ebeeeb2e49998c0ec741883ed08740cd5b2993d0b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "539e1f33857edd441a92c24493e265047b83a8d7c1c140ab22ba91c8f2d03c00", + "entry_digest": "06dabcbb5dbbb34233cd7f9daaa6a25e80b8407609c82b7d28f00c72b599a7a8", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "55d23aa11c362f6e3b2c22602a546170746bb01f0d7dbe60f24e65574ab4f333", + "entry_digest": "8bb8642ad9a663b25ec22120e629c14cb6d720a581db1700d23ed9ec78beff53", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "722fad1be75f8e8837149336d06f3683eaefbdaec50f8f1677a1c6464777b6cb", + "entry_digest": "760d0555b843081447ded1250666b635d4223b64632faccf35bc4ed2afeadbe0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/WatchTermination.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "ae37f3c26e4e7c2d478c592a0b32cc7f2508b4eb2d26f78745621262b61a1ce2", + "entry_digest": "46f00fe52be9cdeba726d6a42cbe28e2ff762d8e185547d7463b6d9c285bc9c7", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "dfaba191724faf87fceea73e18f86bf4b337ba7b4c2c0465bf6e3ba2ee7d41f2", + "entry_digest": "ce446ea3ecb252eaa282af37010a788652b5883f987c92e4aa72084fdc98fe65", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "85bf511e7d8be12a267df3a5ac7a6404752a3e03242eccb3f7e85c1e17a06fcc", + "entry_digest": "ee28b08a89dd5dd58f680ea6f755b3607fb93616ac7c3dae820815b01b40b845", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ac4beecaea165e5e95ab606d82277475fc2d7a6cb5611b0272a8d3dd34f9dc64", + "entry_digest": "14a0593d575717adb53656117358c3a36e4b48f9235b1a6d139deab58f158c04", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ee5ae7f63536e2089a2b51887ed62b8392561052cdaa37c29707e7a10e2136bd", + "entry_digest": "daf1e496e836c46da31eb6ac679b352d7bf615536595be310953eb0ae9a36fd4", + "owners": [ + "aps-cli" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] +} diff --git a/README.md b/README.md index ea0a4a6..7ea685a 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ swift run aps stats --json - `watch --json` is an alias for `--jsonl`; `keys --quiet` prints key names only (handy for `xargs aps reset`). - Shell completions: `aps --generate-completion-script bash|zsh|fish` (install into your shell's completion directory). +`watch` termination is observable in both channels. Exit codes: **0** when `--count` is satisfied, **124** on `--timeout` (GNU convention), **130** on SIGINT, **143** on SIGTERM. In `--jsonl` mode the stream ends with a terminal `{"type":"end","reason":"count|timeout|sigint|sigterm",...}` event and never contains non-JSON lines; in human mode a one-line summary goes to stderr. An unbounded watch prints a one-time stderr hint suggesting `--count` / `--timeout`. + ### Multi-process FileState semantics `aps` expects **one writer per key** at a time. Concurrent `aps set` on the same FileState key (`note` / `profile`) is last-writer-wins and is not locked: AppState writes files non-atomically, so two writers can tear a JSON file. diff --git a/Scripts/smoke.sh b/Scripts/smoke.sh index ea4e5d6..646abcd 100755 --- a/Scripts/smoke.sh +++ b/Scripts/smoke.sh @@ -130,4 +130,23 @@ test "$("$bin" keys --quiet | wc -l | tr -d ' ')" = "7" "$bin" dump | grep -q '"key":"flag"' "$bin" watch counter --count 1 --timeout 2 --json >/dev/null +# Watch termination semantics: exit codes and stream markers. +"$bin" watch counter --count 1 --jsonl >/dev/null +"$bin" watch counter --count 1 --jsonl | grep -q '"reason":"count"' +out="$("$bin" watch counter --timeout 1 --jsonl >/dev/null 2>&1)" && { echo "expected timeout exit" >&2; exit 1; } +test $? -eq 124 || { echo "expected exit 124 for timeout" >&2; exit 1; } +out="$("$bin" watch counter --timeout 1 --jsonl 2>/dev/null || true)" +echo "$out" | grep -q '"reason":"timeout"' +"$bin" watch counter --jsonl > "$APS_HOME/watch.out" 2>/dev/null & +WPID=$! +sleep 1 +kill -INT $WPID +wait $WPID || RC=$? +test "${RC:-0}" -eq 130 || { echo "expected exit 130 for SIGINT, got ${RC:-0}" >&2; exit 1; } +grep -q '"reason":"sigint"' "$APS_HOME/watch.out" +if grep -vq '^{' "$APS_HOME/watch.out"; then + echo "jsonl stream must contain only JSON lines" >&2 + exit 1 +fi + echo "smoke ok" diff --git a/Sources/aps/Aps.swift b/Sources/aps/Aps.swift index 588dee9..dc398d8 100644 --- a/Sources/aps/Aps.swift +++ b/Sources/aps/Aps.swift @@ -142,14 +142,33 @@ extension Aps { let jsonl = jsonl || json let deadline = timeout.map { Date().addingTimeInterval($0) } var emitted = 0 + var stopReason: StopReason? + + let signalBox = SignalBox() + let signalSources = installWatchSignalHandlers(signalBox) + _ = signalSources // retained for the watch lifetime + + if count == nil && timeout == nil { + CLIOutput.writeError("watch: unbounded stream; press Ctrl-C to stop, or use --count/--timeout for bounded runs") + } do { try store.watchBlocking( key, pollInterval: TimeInterval(interval) / 1000.0, shouldContinue: { - if let count, emitted >= count { return false } - if let deadline, Date() >= deadline { return false } + if let count, emitted >= count { + stopReason = .count + return false + } + if let deadline, Date() >= deadline { + stopReason = .timeout + return false + } + if let sig = signalBox.first { + stopReason = .signal(sig) + return false + } return true } ) { value in @@ -157,15 +176,22 @@ extension Aps { if jsonl { // Parse the fresh `value` from watchBlocking. Do not re-query // the store: FileState cache can lag cross-process disk writes. - let event = try? CLIOutput.watchEvent( + if let event = try? CLIOutput.watchEvent( key: key, rawValue: value, timestamp: store.now - ) - if let event, let line = try? CLIOutput.encodeLine(event) { + ), let line = try? CLIOutput.encodeLine(event) { + CLIOutput.writeLine(line) + } else if let line = try? CLIOutput.encodeLine( + CLIOutput.WatchErrorEvent( + key: key.rawValue, + error: "encoding_failed", + message: "value could not be encoded as a watch event", + timestamp: store.now + ) + ) { + // The jsonl stream never carries non-JSON lines. CLIOutput.writeLine(line) - } else { - CLIOutput.writeLine(value) } } else { CLIOutput.writeLine(value) @@ -185,6 +211,24 @@ extension Aps { } try CLIOutput.fail(error, json: jsonl) } + + if let stopReason { + if jsonl { + let event = CLIOutput.WatchEndEvent( + key: key.rawValue, + reason: stopReason.token, + timestamp: store.now + ) + if let line = try? CLIOutput.encodeLine(event) { + CLIOutput.writeLine(line) + } + } else { + CLIOutput.writeError("watch \(key.rawValue): stopped (\(stopReason.summary))") + } + if stopReason.exitCode != 0 { + throw ExitCode(stopReason.exitCode) + } + } } } } diff --git a/Sources/aps/CLIOutput.swift b/Sources/aps/CLIOutput.swift index 8fe7b39..949c68d 100644 --- a/Sources/aps/CLIOutput.swift +++ b/Sources/aps/CLIOutput.swift @@ -52,6 +52,21 @@ enum CLIOutput { } } + /// Terminal machine-mode watch event: the loop stopped, with the reason. + struct WatchEndEvent: Encodable { + let type: String + let key: String + let reason: String + let timestamp: Date + + init(key: String, reason: String, timestamp: Date) { + self.type = "end" + self.key = key + self.reason = reason + self.timestamp = timestamp + } + } + struct StatsPayload: Encodable { let mutationCount: Int let lastMutatedKey: String @@ -176,6 +191,12 @@ enum CLIOutput { } } + static func writeError(_ line: String) { + if let data = (line + "\n").data(using: .utf8) { + FileHandle.standardError.write(data) + } + } + @MainActor static func typedValue(for key: DemoKey, store: StateStore) throws -> JSONValue { try typedValue(for: key, from: store.get(key)) diff --git a/Sources/aps/WatchTermination.swift b/Sources/aps/WatchTermination.swift new file mode 100644 index 0000000..12dc805 --- /dev/null +++ b/Sources/aps/WatchTermination.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Signal handling and termination semantics for `aps watch` (issue #34). +/// The loop stops for one of three reasons (count, timeout, signal) and the +/// reason is observable in both channels: a terminal stream marker and a +/// process exit code (0 count, 124 timeout, 128+signal). + +/// Thread-safe record of received signals. +final class SignalBox: @unchecked Sendable { + private let lock = NSLock() + private var signals: [Int32] = [] + + func mark(_ signal: Int32) { + lock.lock() + signals.append(signal) + lock.unlock() + } + + var first: Int32? { + lock.lock() + defer { lock.unlock() } + return signals.first + } +} + +/// Install DispatchSource handlers for SIGINT/SIGTERM on the main queue. +/// The watch loop's RunLoop draining delivers them; the box is polled by +/// `shouldContinue`. Sources must be retained for the watch's lifetime. +@discardableResult +func installWatchSignalHandlers(_ box: SignalBox) -> [DispatchSourceSignal] { + [SIGINT, SIGTERM].map { sig in + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) + source.setEventHandler { + box.mark(sig) + } + source.resume() + return source + } +} + +/// Why a bounded watch stopped. +enum StopReason: Equatable { + case count + case timeout + case signal(Int32) + + /// Machine-mode reason token for the terminal stream event. + var token: String { + switch self { + case .count: return "count" + case .timeout: return "timeout" + case .signal(let signalNumber): + switch signalNumber { + case SIGINT: return "sigint" + case SIGTERM: return "sigterm" + default: return "signal" + } + } + } + + /// Process exit code: 0 on count (request satisfied), 124 on timeout + /// (GNU convention), 128+signal for signals (130 SIGINT, 143 SIGTERM). + var exitCode: Int32 { + switch self { + case .count: return 0 + case .timeout: return 124 + case .signal(let signalNumber): return 128 + signalNumber + } + } + + /// Human stderr line. + var summary: String { + switch self { + case .count: return "count reached" + case .timeout: return "timeout" + case .signal(let signalNumber): + switch signalNumber { + case SIGINT: return "interrupted (SIGINT)" + case SIGTERM: return "terminated (SIGTERM)" + default: return "signal \(signalNumber)" + } + } + } +} diff --git a/Tests/apsTests/APSTests.swift b/Tests/apsTests/APSTests.swift index 10346bc..6c2dfa3 100644 --- a/Tests/apsTests/APSTests.swift +++ b/Tests/apsTests/APSTests.swift @@ -797,4 +797,33 @@ final class APSTests: XCTestCase { XCTAssertFalse(json.contains("\n")) XCTAssertTrue(json.contains("\"ok\":true")) } + + func testStopReasonTokensAndExitCodes() { + XCTAssertEqual(StopReason.count.token, "count") + XCTAssertEqual(StopReason.timeout.token, "timeout") + XCTAssertEqual(StopReason.signal(SIGINT).token, "sigint") + XCTAssertEqual(StopReason.signal(SIGTERM).token, "sigterm") + XCTAssertEqual(StopReason.signal(20).token, "signal") + + XCTAssertEqual(StopReason.count.exitCode, 0) + XCTAssertEqual(StopReason.timeout.exitCode, 124) + XCTAssertEqual(StopReason.signal(SIGINT).exitCode, 130) + XCTAssertEqual(StopReason.signal(SIGTERM).exitCode, 143) + + XCTAssertEqual(StopReason.count.summary, "count reached") + XCTAssertEqual(StopReason.signal(SIGINT).summary, "interrupted (SIGINT)") + XCTAssertEqual(StopReason.signal(SIGTERM).summary, "terminated (SIGTERM)") + } + + func testWatchEndEventEncodesTerminalMarker() throws { + let event = CLIOutput.WatchEndEvent( + key: "counter", + reason: StopReason.timeout.token, + timestamp: Date(timeIntervalSince1970: 0) + ) + let line = try CLIOutput.encodeLine(event) + XCTAssertTrue(line.contains(#""type":"end""#)) + XCTAssertTrue(line.contains(#""reason":"timeout""#)) + XCTAssertTrue(line.contains(#""key":"counter""#)) + } } diff --git a/specs/aps-cli/aps-cli.spec.md b/specs/aps-cli/aps-cli.spec.md index 511c0b7..2eaf0c5 100644 --- a/specs/aps-cli/aps-cli.spec.md +++ b/specs/aps-cli/aps-cli.spec.md @@ -1,12 +1,13 @@ --- module: aps-cli -version: 21 +version: 22 status: active files: - Sources/aps/Aps.swift - Sources/aps/CLIOutput.swift - Sources/aps/DemoKey.swift - Sources/aps/TTY.swift + - Sources/aps/WatchTermination.swift db_tables: [] depends_on: - state-store @@ -76,6 +77,13 @@ names only. Machine shapes are additive-only contracts; human text may evolve. 4. `watch` must flush each printed value immediately when stdout is not a TTY. 5. `keys` and `--help` do not mutate application state. +6. `watch` termination is observable in both channels: a terminal + `{"type":"end","reason":"count|timeout|sigint|sigterm"}` event in `--jsonl` + mode or a stderr line in human mode, with exit codes 0 (count), 124 + (timeout), 128+signal (130 SIGINT, 143 SIGTERM). The `--jsonl` stream never + contains non-JSON lines. An unbounded watch prints a one-time stderr hint + suggesting `--count` / `--timeout`. + ## Behavioral Examples ``` @@ -160,3 +168,4 @@ Exit codes (sysexits-aligned): | 2026-07-18 | CHG-0021-error-contract-exit-code-taxonomy-and-json-error-envelope-issue-31-rebuilt-on: Error contract: exit-code taxonomy and JSON error envelope (issue 31, rebuilt on corruptState main) | | 2026-07-19 | CHG-0021-error-contract-exit-code-taxonomy-and-json-error-envelope-issue-31-rebuilt-on: Error contract: exit-code taxonomy and JSON error envelope (issue 31, rebuilt on corruptState main) | | 2026-07-19 | CHG-0022-tty-aware-output-under-the-git-porcelain-rule-issue-33: TTY-aware output under the git porcelain rule (issue 33) | +| 2026-07-19 | CHG-0023-watch-signal-handling-and-termination-semantics-issue-34: Watch signal handling and termination semantics (issue 34) | diff --git a/specs/aps-cli/requirements.md b/specs/aps-cli/requirements.md index 30ac845..48e3965 100644 --- a/specs/aps-cli/requirements.md +++ b/specs/aps-cli/requirements.md @@ -70,12 +70,15 @@ Acceptance Criteria ### REQ-aps-cli-012 -`watch` SHALL support `--count`, `--timeout`, and `--jsonl`. +`watch` SHALL support `--count`, `--timeout`, and `--jsonl`, and SHALL handle SIGINT/SIGTERM with observable termination semantics. Acceptance Criteria -- `--count` stops after that many printed values including the initial value. -- `--timeout` stops after the given seconds. -- `--jsonl` emits one JSON object per line. +- `--count` stops after that many printed values including the initial value (exit 0). +- `--timeout` stops after the given seconds (exit 124). +- SIGINT/SIGTERM stop the loop cleanly (exit 130 / 143). +- The stop reason appears as a terminal `{"type":"end","reason":...}` event in `--jsonl` mode or a stderr line in human mode. +- The `--jsonl` stream never contains non-JSON lines. +- An unbounded watch prints a one-time stderr hint suggesting `--count` / `--timeout`. ### REQ-aps-cli-013 From a26e791b9d73198c4d426c1df08d7144ac5983f1 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 22:05:00 -0600 Subject: [PATCH 2/7] Harden SIGINT smoke test with a bounded wait The macOS self-hosted runner hung 30m on wait $WPID (orphan aps process). Poll with a deadline and SIGKILL fallback; still asserts exit 130 when the signal works. --- .../verification-attempts.json | 24 +++ .../verification-attempts.json | 18 ++ .../verification-attempts.json | 19 ++ .../approvals.json | 181 +++++++++++++++++- .../state.json | 2 +- .../verification-attempts.json | 17 ++ .../verification.json | 12 +- Scripts/smoke.sh | 12 ++ 8 files changed, 277 insertions(+), 8 deletions(-) diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json index f7b776d..74dd620 100644 --- a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json @@ -624,6 +624,30 @@ "REQ-state-store-010", "REQ-state-store-011" ] + }, + { + "timestamp": 1784433618, + "commit": "74bc5f65bf74c531741d3974af3301901fa87d9b", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "cc5e57c94e1233874bbdf273f510e6c91bca4414fb8147d6b0c91de6d42109d1", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] } ] } diff --git a/.specsync/changes/CHG-0011-dogfood-observeddependency-demostats-for-issue-18/verification-attempts.json b/.specsync/changes/CHG-0011-dogfood-observeddependency-demostats-for-issue-18/verification-attempts.json index 9771671..1a7f3fe 100644 --- a/.specsync/changes/CHG-0011-dogfood-observeddependency-demostats-for-issue-18/verification-attempts.json +++ b/.specsync/changes/CHG-0011-dogfood-observeddependency-demostats-for-issue-18/verification-attempts.json @@ -324,6 +324,24 @@ "REQ-aps-cli-014", "REQ-state-store-012" ] + }, + { + "timestamp": 1784433690, + "commit": "74bc5f65bf74c531741d3974af3301901fa87d9b", + "contract_digest": "1defd9f7ba999c7d4156cd0b80b236b4a173d5ba0985832e9f89b8b2ed5155ca", + "workspace_digest": "cc5e57c94e1233874bbdf273f510e6c91bca4414fb8147d6b0c91de6d42109d1", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-014", + "REQ-state-store-012" + ] } ] } diff --git a/.specsync/changes/CHG-0012-dogfood-securestate-secret-keychain-demo-key-for-issue-16/verification-attempts.json b/.specsync/changes/CHG-0012-dogfood-securestate-secret-keychain-demo-key-for-issue-16/verification-attempts.json index 3426dfc..27b5e79 100644 --- a/.specsync/changes/CHG-0012-dogfood-securestate-secret-keychain-demo-key-for-issue-16/verification-attempts.json +++ b/.specsync/changes/CHG-0012-dogfood-securestate-secret-keychain-demo-key-for-issue-16/verification-attempts.json @@ -323,6 +323,25 @@ "REQ-aps-cli-015", "REQ-state-store-013" ] + }, + { + "timestamp": 1784433741, + "commit": "74bc5f65bf74c531741d3974af3301901fa87d9b", + "contract_digest": "914c204b1d836d6b3de14e4908e2813862014a023e5d4244f1758d9486b3cd80", + "workspace_digest": "cc5e57c94e1233874bbdf273f510e6c91bca4414fb8147d6b0c91de6d42109d1", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-015", + "REQ-state-store-013" + ] } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json index 416afc6..1f8e013 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json @@ -13,7 +13,186 @@ "timestamp": 1784424361, "digest": "f13f1d8e9ddb217cadc43217caf93e5be51d1cedcd341e3be4fd3a095b4b0876", "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784433799, + "digest": "4b20014652de2354fa495f65521089c1e568403589ecbdc0b0ec124e7fd6d189", + "note": null } ], - "reopenings": [] + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0021-watch-signal-handling-and-termination-semantics-issue-34", + "actor": "0xLeif", + "reason": "Harden SIGINT smoke wait for CI runners; rebinding evidence", + "timestamp": 1784433770, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784424361, + "digest": "f13f1d8e9ddb217cadc43217caf93e5be51d1cedcd341e3be4fd3a095b4b0876", + "note": null + }, + "prior_verification": { + "timestamp": 1784424356, + "commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "4d2e9a44083c94210666bfa389388c9b32ab3bfbb98db2476ba49c918130629d", + "acceptance_input_digest": "867757265c0081cdd804e8b95dc4ca37bb568595af91811d65968d72d65641ff", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "bd09a0ace8b7d37cfc0b686ab0ff2d3094fdb015cbb2cd09e6e6a5db6961439f", + "entry_digest": "e9f52413e2cae8ce5dcf4d5ebfb03b873d245731b80c59d18d3c9cc3c19c009f", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "1adbf9dab00c1129b9e50c355a9098c75ec4b534af048e8a953c9037c8e3f9cb", + "entry_digest": "bf24557a813a8fdfb925a55ebeeeb2e49998c0ec741883ed08740cd5b2993d0b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "539e1f33857edd441a92c24493e265047b83a8d7c1c140ab22ba91c8f2d03c00", + "entry_digest": "06dabcbb5dbbb34233cd7f9daaa6a25e80b8407609c82b7d28f00c72b599a7a8", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "55d23aa11c362f6e3b2c22602a546170746bb01f0d7dbe60f24e65574ab4f333", + "entry_digest": "8bb8642ad9a663b25ec22120e629c14cb6d720a581db1700d23ed9ec78beff53", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "722fad1be75f8e8837149336d06f3683eaefbdaec50f8f1677a1c6464777b6cb", + "entry_digest": "760d0555b843081447ded1250666b635d4223b64632faccf35bc4ed2afeadbe0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/WatchTermination.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "ae37f3c26e4e7c2d478c592a0b32cc7f2508b4eb2d26f78745621262b61a1ce2", + "entry_digest": "46f00fe52be9cdeba726d6a42cbe28e2ff762d8e185547d7463b6d9c285bc9c7", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "dfaba191724faf87fceea73e18f86bf4b337ba7b4c2c0465bf6e3ba2ee7d41f2", + "entry_digest": "ce446ea3ecb252eaa282af37010a788652b5883f987c92e4aa72084fdc98fe65", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "85bf511e7d8be12a267df3a5ac7a6404752a3e03242eccb3f7e85c1e17a06fcc", + "entry_digest": "ee28b08a89dd5dd58f680ea6f755b3607fb93616ac7c3dae820815b01b40b845", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ac4beecaea165e5e95ab606d82277475fc2d7a6cb5611b0272a8d3dd34f9dc64", + "entry_digest": "14a0593d575717adb53656117358c3a36e4b48f9235b1a6d139deab58f158c04", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ee5ae7f63536e2089a2b51887ed62b8392561052cdaa37c29707e7a10e2136bd", + "entry_digest": "daf1e496e836c46da31eb6ac679b352d7bf615536595be310953eb0ae9a36fd4", + "owners": [ + "aps-cli" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] + }, + "stale_acceptance_input_digest": "867757265c0081cdd804e8b95dc4ca37bb568595af91811d65968d72d65641ff", + "current_acceptance_input_digest": "e657f9f9ba327edcd76dce50c1a65c2751990fd754245bb12fd18e51679daf74" + } + ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json index 73e4498..bfdf104 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", "created_at": 1784424255, - "updated_at": 1784424361, + "updated_at": 1784433799, "affected_specs": [ "aps-cli" ], diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json index 366b377..5cfb64a 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json @@ -17,6 +17,23 @@ "requirement_ids": [ "REQ-aps-cli-012" ] + }, + { + "timestamp": 1784433785, + "commit": "74bc5f65bf74c531741d3974af3301901fa87d9b", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "cc5e57c94e1233874bbdf273f510e6c91bca4414fb8147d6b0c91de6d42109d1", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json index 939c1fd..9085a3a 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1784424356, - "commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", + "timestamp": 1784433785, + "commit": "74bc5f65bf74c531741d3974af3301901fa87d9b", "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", - "workspace_digest": "4d2e9a44083c94210666bfa389388c9b32ab3bfbb98db2476ba49c918130629d", - "acceptance_input_digest": "867757265c0081cdd804e8b95dc4ca37bb568595af91811d65968d72d65641ff", + "workspace_digest": "cc5e57c94e1233874bbdf273f510e6c91bca4414fb8147d6b0c91de6d42109d1", + "acceptance_input_digest": "e657f9f9ba327edcd76dce50c1a65c2751990fd754245bb12fd18e51679daf74", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -31,8 +31,8 @@ "path": "Scripts/smoke.sh", "kind": "file", "mode": 33261, - "payload_digest": "539e1f33857edd441a92c24493e265047b83a8d7c1c140ab22ba91c8f2d03c00", - "entry_digest": "06dabcbb5dbbb34233cd7f9daaa6a25e80b8407609c82b7d28f00c72b599a7a8", + "payload_digest": "da18c7227ca19b2a29b2ee90d6e4056cb6ab803a4aea5528d4c6af6c1daa2842", + "entry_digest": "c95bfcb30a4031b51c42319075bceceae03660b7d019b414cce45446895f4700", "owners": [ "@exact:delivery" ] diff --git a/Scripts/smoke.sh b/Scripts/smoke.sh index 646abcd..b7a0c34 100755 --- a/Scripts/smoke.sh +++ b/Scripts/smoke.sh @@ -141,6 +141,18 @@ echo "$out" | grep -q '"reason":"timeout"' WPID=$! sleep 1 kill -INT $WPID +# Bounded wait: SIGINT must stop the watch within a few seconds, even on +# runners where signal delivery differs from a local shell. +RC=0 +for _ in $(seq 1 20); do + kill -0 $WPID 2>/dev/null || break + sleep 0.5 +done +if kill -0 $WPID 2>/dev/null; then + echo "watch did not stop on SIGINT" >&2 + kill -KILL $WPID 2>/dev/null || true + exit 1 +fi wait $WPID || RC=$? test "${RC:-0}" -eq 130 || { echo "expected exit 130 for SIGINT, got ${RC:-0}" >&2; exit 1; } grep -q '"reason":"sigint"' "$APS_HOME/watch.out" From 67a06d14bd507e5677dd916abcb92aeac9bc1e36 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 23:11:09 -0600 Subject: [PATCH 3/7] Fix signal blocking: sigprocmask(SIG_BLOCK) instead of SIG_IGN An ignored disposition also suppresses kqueue signal delivery on some platforms; the macOS CI runner lost SIGINT while local shells received it. Blocking keeps dispatch-source delivery intact everywhere (verified: exit 130 on SIGINT, 143 on SIGTERM locally). --- .../verification-attempts.json | 24 +++ .../approvals.json | 178 ++++++++++++++++++ .../state.json | 2 +- .../verification-attempts.json | 17 ++ .../verification.json | 12 +- Sources/aps/WatchTermination.swift | 8 +- 6 files changed, 233 insertions(+), 8 deletions(-) diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json index 74dd620..672c2af 100644 --- a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json @@ -648,6 +648,30 @@ "REQ-state-store-010", "REQ-state-store-011" ] + }, + { + "timestamp": 1784437771, + "commit": "612ca8753cbc8b063d7549e74f653f159889523c", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "a8206c48006dbd00569ebf8117ea5c3a07ab2eb80348cdf7e37a26baa06226e2", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json index 1f8e013..7a0b61b 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json @@ -20,6 +20,13 @@ "timestamp": 1784433799, "digest": "4b20014652de2354fa495f65521089c1e568403589ecbdc0b0ec124e7fd6d189", "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784437806, + "digest": "29774a2a9bd121935670180fdd2a5313c566eb3a3e44537614de5211962beb91", + "note": null } ], "reopenings": [ @@ -193,6 +200,177 @@ }, "stale_acceptance_input_digest": "867757265c0081cdd804e8b95dc4ca37bb568595af91811d65968d72d65641ff", "current_acceptance_input_digest": "e657f9f9ba327edcd76dce50c1a65c2751990fd754245bb12fd18e51679daf74" + }, + { + "schema_version": 1, + "change_id": "CHG-0021-watch-signal-handling-and-termination-semantics-issue-34", + "actor": "0xLeif", + "reason": "sigprocmask signal blocking fix for Darwin runners; rebinding evidence", + "timestamp": 1784437785, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784433799, + "digest": "4b20014652de2354fa495f65521089c1e568403589ecbdc0b0ec124e7fd6d189", + "note": null + }, + "prior_verification": { + "timestamp": 1784433785, + "commit": "74bc5f65bf74c531741d3974af3301901fa87d9b", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "cc5e57c94e1233874bbdf273f510e6c91bca4414fb8147d6b0c91de6d42109d1", + "acceptance_input_digest": "e657f9f9ba327edcd76dce50c1a65c2751990fd754245bb12fd18e51679daf74", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "bd09a0ace8b7d37cfc0b686ab0ff2d3094fdb015cbb2cd09e6e6a5db6961439f", + "entry_digest": "e9f52413e2cae8ce5dcf4d5ebfb03b873d245731b80c59d18d3c9cc3c19c009f", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "1adbf9dab00c1129b9e50c355a9098c75ec4b534af048e8a953c9037c8e3f9cb", + "entry_digest": "bf24557a813a8fdfb925a55ebeeeb2e49998c0ec741883ed08740cd5b2993d0b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "da18c7227ca19b2a29b2ee90d6e4056cb6ab803a4aea5528d4c6af6c1daa2842", + "entry_digest": "c95bfcb30a4031b51c42319075bceceae03660b7d019b414cce45446895f4700", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "55d23aa11c362f6e3b2c22602a546170746bb01f0d7dbe60f24e65574ab4f333", + "entry_digest": "8bb8642ad9a663b25ec22120e629c14cb6d720a581db1700d23ed9ec78beff53", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "722fad1be75f8e8837149336d06f3683eaefbdaec50f8f1677a1c6464777b6cb", + "entry_digest": "760d0555b843081447ded1250666b635d4223b64632faccf35bc4ed2afeadbe0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/WatchTermination.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "ae37f3c26e4e7c2d478c592a0b32cc7f2508b4eb2d26f78745621262b61a1ce2", + "entry_digest": "46f00fe52be9cdeba726d6a42cbe28e2ff762d8e185547d7463b6d9c285bc9c7", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "dfaba191724faf87fceea73e18f86bf4b337ba7b4c2c0465bf6e3ba2ee7d41f2", + "entry_digest": "ce446ea3ecb252eaa282af37010a788652b5883f987c92e4aa72084fdc98fe65", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "85bf511e7d8be12a267df3a5ac7a6404752a3e03242eccb3f7e85c1e17a06fcc", + "entry_digest": "ee28b08a89dd5dd58f680ea6f755b3607fb93616ac7c3dae820815b01b40b845", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ac4beecaea165e5e95ab606d82277475fc2d7a6cb5611b0272a8d3dd34f9dc64", + "entry_digest": "14a0593d575717adb53656117358c3a36e4b48f9235b1a6d139deab58f158c04", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ee5ae7f63536e2089a2b51887ed62b8392561052cdaa37c29707e7a10e2136bd", + "entry_digest": "daf1e496e836c46da31eb6ac679b352d7bf615536595be310953eb0ae9a36fd4", + "owners": [ + "aps-cli" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] + }, + "stale_acceptance_input_digest": "e657f9f9ba327edcd76dce50c1a65c2751990fd754245bb12fd18e51679daf74", + "current_acceptance_input_digest": "0ce28119d82d404a3f9ef802ec7a6a1999d2d30089aca6d9715bbce544228a1f" } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json index bfdf104..6750750 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", "created_at": 1784424255, - "updated_at": 1784433799, + "updated_at": 1784437806, "affected_specs": [ "aps-cli" ], diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json index 5cfb64a..d32fc99 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json @@ -34,6 +34,23 @@ "requirement_ids": [ "REQ-aps-cli-012" ] + }, + { + "timestamp": 1784437798, + "commit": "612ca8753cbc8b063d7549e74f653f159889523c", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "a8206c48006dbd00569ebf8117ea5c3a07ab2eb80348cdf7e37a26baa06226e2", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json index 9085a3a..d78b219 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1784433785, - "commit": "74bc5f65bf74c531741d3974af3301901fa87d9b", + "timestamp": 1784437798, + "commit": "612ca8753cbc8b063d7549e74f653f159889523c", "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", - "workspace_digest": "cc5e57c94e1233874bbdf273f510e6c91bca4414fb8147d6b0c91de6d42109d1", - "acceptance_input_digest": "e657f9f9ba327edcd76dce50c1a65c2751990fd754245bb12fd18e51679daf74", + "workspace_digest": "a8206c48006dbd00569ebf8117ea5c3a07ab2eb80348cdf7e37a26baa06226e2", + "acceptance_input_digest": "0ce28119d82d404a3f9ef802ec7a6a1999d2d30089aca6d9715bbce544228a1f", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -61,8 +61,8 @@ "path": "Sources/aps/WatchTermination.swift", "kind": "file", "mode": 33188, - "payload_digest": "ae37f3c26e4e7c2d478c592a0b32cc7f2508b4eb2d26f78745621262b61a1ce2", - "entry_digest": "46f00fe52be9cdeba726d6a42cbe28e2ff762d8e185547d7463b6d9c285bc9c7", + "payload_digest": "18c5ac3f1d4a8b1396965517ced078d77883eec23dfff174c963a646c15db8d3", + "entry_digest": "fc23880b9b9a827ea48dd1189fda8e8b53a20651e4feb8a53976b5d89ee41eb2", "owners": [ "aps-cli" ] diff --git a/Sources/aps/WatchTermination.swift b/Sources/aps/WatchTermination.swift index 12dc805..22f8cb7 100644 --- a/Sources/aps/WatchTermination.swift +++ b/Sources/aps/WatchTermination.swift @@ -29,7 +29,13 @@ final class SignalBox: @unchecked Sendable { @discardableResult func installWatchSignalHandlers(_ box: SignalBox) -> [DispatchSourceSignal] { [SIGINT, SIGTERM].map { sig in - signal(sig, SIG_IGN) + // Block (not SIG_IGN): an ignored disposition also suppresses kqueue + // signal delivery on some platforms, which is how the macOS CI runner + // silently lost SIGINT while local shells did not. + var set = sigset_t() + sigemptyset(&set) + sigaddset(&set, sig) + sigprocmask(SIG_BLOCK, &set, nil) let source = DispatchSource.makeSignalSource(signal: sig, queue: .main) source.setEventHandler { box.mark(sig) From da615d287a4105ffb085f4b61c4a9ddd2e24391c Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 23:27:36 -0600 Subject: [PATCH 4/7] Reset inherited SIGINT ignore disposition; gate POSIX signals for Windows CI agents run job children with SIGINT ignored, which also suppresses kqueue delivery - so the dispatch source never fired on the macOS runner while local shells (default disposition) worked. Signal(SIG_DFL) before blocking, and compactMap-nil the whole mechanism on Windows where POSIX signals do not exist. --- .../verification-attempts.json | 24 +++ .../approvals.json | 178 ++++++++++++++++++ .../state.json | 2 +- .../verification-attempts.json | 17 ++ .../verification.json | 12 +- Sources/aps/WatchTermination.swift | 15 +- 6 files changed, 237 insertions(+), 11 deletions(-) diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json index 672c2af..56bd7ee 100644 --- a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json @@ -672,6 +672,30 @@ "REQ-state-store-010", "REQ-state-store-011" ] + }, + { + "timestamp": 1784438770, + "commit": "d39cd25e05dd197f80ae835b5baba39a6917db2f", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "8fcaebc50225b5f1bce4d95e06ff1192587cf1ce767813f3b550adbd09495178", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json index 7a0b61b..d8cd4ce 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json @@ -27,6 +27,13 @@ "timestamp": 1784437806, "digest": "29774a2a9bd121935670180fdd2a5313c566eb3a3e44537614de5211962beb91", "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784438805, + "digest": "73f13914de5b3b98580d7b136e930d66bedc03f783c6b94c10ee6f210fca1f89", + "note": null } ], "reopenings": [ @@ -371,6 +378,177 @@ }, "stale_acceptance_input_digest": "e657f9f9ba327edcd76dce50c1a65c2751990fd754245bb12fd18e51679daf74", "current_acceptance_input_digest": "0ce28119d82d404a3f9ef802ec7a6a1999d2d30089aca6d9715bbce544228a1f" + }, + { + "schema_version": 1, + "change_id": "CHG-0021-watch-signal-handling-and-termination-semantics-issue-34", + "actor": "0xLeif", + "reason": "SIG_DFL reset plus Windows gate for signal handlers; rebinding evidence", + "timestamp": 1784438785, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784437806, + "digest": "29774a2a9bd121935670180fdd2a5313c566eb3a3e44537614de5211962beb91", + "note": null + }, + "prior_verification": { + "timestamp": 1784437798, + "commit": "612ca8753cbc8b063d7549e74f653f159889523c", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "a8206c48006dbd00569ebf8117ea5c3a07ab2eb80348cdf7e37a26baa06226e2", + "acceptance_input_digest": "0ce28119d82d404a3f9ef802ec7a6a1999d2d30089aca6d9715bbce544228a1f", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "bd09a0ace8b7d37cfc0b686ab0ff2d3094fdb015cbb2cd09e6e6a5db6961439f", + "entry_digest": "e9f52413e2cae8ce5dcf4d5ebfb03b873d245731b80c59d18d3c9cc3c19c009f", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "1adbf9dab00c1129b9e50c355a9098c75ec4b534af048e8a953c9037c8e3f9cb", + "entry_digest": "bf24557a813a8fdfb925a55ebeeeb2e49998c0ec741883ed08740cd5b2993d0b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "da18c7227ca19b2a29b2ee90d6e4056cb6ab803a4aea5528d4c6af6c1daa2842", + "entry_digest": "c95bfcb30a4031b51c42319075bceceae03660b7d019b414cce45446895f4700", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "55d23aa11c362f6e3b2c22602a546170746bb01f0d7dbe60f24e65574ab4f333", + "entry_digest": "8bb8642ad9a663b25ec22120e629c14cb6d720a581db1700d23ed9ec78beff53", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "722fad1be75f8e8837149336d06f3683eaefbdaec50f8f1677a1c6464777b6cb", + "entry_digest": "760d0555b843081447ded1250666b635d4223b64632faccf35bc4ed2afeadbe0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/WatchTermination.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "18c5ac3f1d4a8b1396965517ced078d77883eec23dfff174c963a646c15db8d3", + "entry_digest": "fc23880b9b9a827ea48dd1189fda8e8b53a20651e4feb8a53976b5d89ee41eb2", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "dfaba191724faf87fceea73e18f86bf4b337ba7b4c2c0465bf6e3ba2ee7d41f2", + "entry_digest": "ce446ea3ecb252eaa282af37010a788652b5883f987c92e4aa72084fdc98fe65", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "85bf511e7d8be12a267df3a5ac7a6404752a3e03242eccb3f7e85c1e17a06fcc", + "entry_digest": "ee28b08a89dd5dd58f680ea6f755b3607fb93616ac7c3dae820815b01b40b845", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ac4beecaea165e5e95ab606d82277475fc2d7a6cb5611b0272a8d3dd34f9dc64", + "entry_digest": "14a0593d575717adb53656117358c3a36e4b48f9235b1a6d139deab58f158c04", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ee5ae7f63536e2089a2b51887ed62b8392561052cdaa37c29707e7a10e2136bd", + "entry_digest": "daf1e496e836c46da31eb6ac679b352d7bf615536595be310953eb0ae9a36fd4", + "owners": [ + "aps-cli" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] + }, + "stale_acceptance_input_digest": "0ce28119d82d404a3f9ef802ec7a6a1999d2d30089aca6d9715bbce544228a1f", + "current_acceptance_input_digest": "00061dbf1a476d0ce19ea1b4fc404dc9c110f1e9e45171bf83ac5bf40a8f7e68" } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json index 6750750..b76f9d1 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", "created_at": 1784424255, - "updated_at": 1784437806, + "updated_at": 1784438805, "affected_specs": [ "aps-cli" ], diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json index d32fc99..bee200d 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json @@ -51,6 +51,23 @@ "requirement_ids": [ "REQ-aps-cli-012" ] + }, + { + "timestamp": 1784438798, + "commit": "d39cd25e05dd197f80ae835b5baba39a6917db2f", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "8fcaebc50225b5f1bce4d95e06ff1192587cf1ce767813f3b550adbd09495178", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json index d78b219..9b00c18 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1784437798, - "commit": "612ca8753cbc8b063d7549e74f653f159889523c", + "timestamp": 1784438798, + "commit": "d39cd25e05dd197f80ae835b5baba39a6917db2f", "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", - "workspace_digest": "a8206c48006dbd00569ebf8117ea5c3a07ab2eb80348cdf7e37a26baa06226e2", - "acceptance_input_digest": "0ce28119d82d404a3f9ef802ec7a6a1999d2d30089aca6d9715bbce544228a1f", + "workspace_digest": "8fcaebc50225b5f1bce4d95e06ff1192587cf1ce767813f3b550adbd09495178", + "acceptance_input_digest": "00061dbf1a476d0ce19ea1b4fc404dc9c110f1e9e45171bf83ac5bf40a8f7e68", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -61,8 +61,8 @@ "path": "Sources/aps/WatchTermination.swift", "kind": "file", "mode": 33188, - "payload_digest": "18c5ac3f1d4a8b1396965517ced078d77883eec23dfff174c963a646c15db8d3", - "entry_digest": "fc23880b9b9a827ea48dd1189fda8e8b53a20651e4feb8a53976b5d89ee41eb2", + "payload_digest": "d68fcc1d7c8f5268d2d46003459c356e0b68aa16dd8412177b11eb4d744acf9f", + "entry_digest": "4190521ffcec836800af83d92de4569d88b056ba264992b8e2346786228bd01e", "owners": [ "aps-cli" ] diff --git a/Sources/aps/WatchTermination.swift b/Sources/aps/WatchTermination.swift index 22f8cb7..988f8be 100644 --- a/Sources/aps/WatchTermination.swift +++ b/Sources/aps/WatchTermination.swift @@ -28,10 +28,16 @@ final class SignalBox: @unchecked Sendable { /// `shouldContinue`. Sources must be retained for the watch's lifetime. @discardableResult func installWatchSignalHandlers(_ box: SignalBox) -> [DispatchSourceSignal] { - [SIGINT, SIGTERM].map { sig in - // Block (not SIG_IGN): an ignored disposition also suppresses kqueue - // signal delivery on some platforms, which is how the macOS CI runner - // silently lost SIGINT while local shells did not. + [SIGINT, SIGTERM].compactMap { sig in + #if os(Windows) + // POSIX signals do not exist on Windows; console control handlers are + // a future addition there. Return no sources; bounds still work. + return nil + #else + // Reset any inherited ignore disposition (CI agents ignore SIGINT for + // job children, which also suppresses kqueue delivery), then BLOCK the + // signal so the dispatch source is the only consumer. + signal(sig, SIG_DFL) var set = sigset_t() sigemptyset(&set) sigaddset(&set, sig) @@ -42,6 +48,7 @@ func installWatchSignalHandlers(_ box: SignalBox) -> [DispatchSourceSignal] { } source.resume() return source + #endif } } From 79e07ac744812d5635c0caa851ffd815ee6dd788 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 23:36:18 -0600 Subject: [PATCH 5/7] Smoke: assert signal stop via SIGTERM (runner masks SIGINT in job children) --- Scripts/smoke.sh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Scripts/smoke.sh b/Scripts/smoke.sh index b7a0c34..e705ca8 100755 --- a/Scripts/smoke.sh +++ b/Scripts/smoke.sh @@ -140,22 +140,23 @@ echo "$out" | grep -q '"reason":"timeout"' "$bin" watch counter --jsonl > "$APS_HOME/watch.out" 2>/dev/null & WPID=$! sleep 1 -kill -INT $WPID -# Bounded wait: SIGINT must stop the watch within a few seconds, even on -# runners where signal delivery differs from a local shell. +# SIGTERM: the GitHub runner co-opts SIGINT for job cancellation and masks it +# in job children; TERM exercises the same handler path and exit semantics. +kill -TERM $WPID +# Bounded wait: the signal must stop the watch within a few seconds. RC=0 for _ in $(seq 1 20); do kill -0 $WPID 2>/dev/null || break sleep 0.5 done if kill -0 $WPID 2>/dev/null; then - echo "watch did not stop on SIGINT" >&2 + echo "watch did not stop on signal" >&2 kill -KILL $WPID 2>/dev/null || true exit 1 fi wait $WPID || RC=$? -test "${RC:-0}" -eq 130 || { echo "expected exit 130 for SIGINT, got ${RC:-0}" >&2; exit 1; } -grep -q '"reason":"sigint"' "$APS_HOME/watch.out" +test "${RC:-0}" -eq 143 || { echo "expected exit 143 for SIGTERM, got ${RC:-0}" >&2; exit 1; } +grep -q '"reason":"sigterm"' "$APS_HOME/watch.out" if grep -vq '^{' "$APS_HOME/watch.out"; then echo "jsonl stream must contain only JSON lines" >&2 exit 1 From 181b51dd5ce9f984587fde15b913588e73ea0873 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 18 Jul 2026 23:58:21 -0600 Subject: [PATCH 6/7] Fix signal loss in release builds: keep dispatch sources alive past the loop The real bug all along: '_ = signalSources' discarded the dispatch sources immediately; debug kept them incidentally, release optimized them away so no signal ever fired (initially misdiagnosed as runner signal masking). Post-loop cancel() is an actual use that pins the lifetime. Smoke's signal assertion returns to SIGINT (exit 130) with the bounded-wait guard kept. --- .../verification-attempts.json | 24 +++ .../verification-attempts.json | 18 ++ .../verification-attempts.json | 19 ++ .../verification-attempts.json | 18 ++ .../verification-attempts.json | 20 ++ .../approvals.json | 178 ++++++++++++++++++ .../state.json | 2 +- .../verification-attempts.json | 17 ++ .../verification.json | 16 +- Scripts/smoke.sh | 8 +- Sources/aps/Aps.swift | 6 +- 11 files changed, 311 insertions(+), 15 deletions(-) diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json index 56bd7ee..69dd308 100644 --- a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json @@ -696,6 +696,30 @@ "REQ-state-store-010", "REQ-state-store-011" ] + }, + { + "timestamp": 1784440389, + "commit": "384c2b26692a3020dbbeeabece9e45f4cf4d7c3d", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "64c0bcba0fac8f0ef02f10f4b1851cff103ae5c2929a3e0853b581a88afb8daa", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] } ] } diff --git a/.specsync/changes/CHG-0011-dogfood-observeddependency-demostats-for-issue-18/verification-attempts.json b/.specsync/changes/CHG-0011-dogfood-observeddependency-demostats-for-issue-18/verification-attempts.json index 1a7f3fe..97abbff 100644 --- a/.specsync/changes/CHG-0011-dogfood-observeddependency-demostats-for-issue-18/verification-attempts.json +++ b/.specsync/changes/CHG-0011-dogfood-observeddependency-demostats-for-issue-18/verification-attempts.json @@ -342,6 +342,24 @@ "REQ-aps-cli-014", "REQ-state-store-012" ] + }, + { + "timestamp": 1784440418, + "commit": "384c2b26692a3020dbbeeabece9e45f4cf4d7c3d", + "contract_digest": "1defd9f7ba999c7d4156cd0b80b236b4a173d5ba0985832e9f89b8b2ed5155ca", + "workspace_digest": "64c0bcba0fac8f0ef02f10f4b1851cff103ae5c2929a3e0853b581a88afb8daa", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-014", + "REQ-state-store-012" + ] } ] } diff --git a/.specsync/changes/CHG-0012-dogfood-securestate-secret-keychain-demo-key-for-issue-16/verification-attempts.json b/.specsync/changes/CHG-0012-dogfood-securestate-secret-keychain-demo-key-for-issue-16/verification-attempts.json index 27b5e79..500d2ef 100644 --- a/.specsync/changes/CHG-0012-dogfood-securestate-secret-keychain-demo-key-for-issue-16/verification-attempts.json +++ b/.specsync/changes/CHG-0012-dogfood-securestate-secret-keychain-demo-key-for-issue-16/verification-attempts.json @@ -342,6 +342,25 @@ "REQ-aps-cli-015", "REQ-state-store-013" ] + }, + { + "timestamp": 1784440461, + "commit": "384c2b26692a3020dbbeeabece9e45f4cf4d7c3d", + "contract_digest": "914c204b1d836d6b3de14e4908e2813862014a023e5d4244f1758d9486b3cd80", + "workspace_digest": "64c0bcba0fac8f0ef02f10f4b1851cff103ae5c2929a3e0853b581a88afb8daa", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-015", + "REQ-state-store-013" + ] } ] } diff --git a/.specsync/changes/CHG-0013-dogfood-appstate-slice-via-profilename-for-issue-17/verification-attempts.json b/.specsync/changes/CHG-0013-dogfood-appstate-slice-via-profilename-for-issue-17/verification-attempts.json index 59fae5a..2fbe343 100644 --- a/.specsync/changes/CHG-0013-dogfood-appstate-slice-via-profilename-for-issue-17/verification-attempts.json +++ b/.specsync/changes/CHG-0013-dogfood-appstate-slice-via-profilename-for-issue-17/verification-attempts.json @@ -270,6 +270,24 @@ "REQ-aps-cli-016", "REQ-state-store-014" ] + }, + { + "timestamp": 1784440555, + "commit": "384c2b26692a3020dbbeeabece9e45f4cf4d7c3d", + "contract_digest": "55d93919744f623103fb1d77e2cdb833a2d95075e3fc62ac31c6d09d506223b6", + "workspace_digest": "64c0bcba0fac8f0ef02f10f4b1851cff103ae5c2929a3e0853b581a88afb8daa", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-016", + "REQ-state-store-014" + ] } ] } diff --git a/.specsync/changes/CHG-0016-loud-torn-filestate-reads-and-document-multi-writer-semantics-for-issue-38/verification-attempts.json b/.specsync/changes/CHG-0016-loud-torn-filestate-reads-and-document-multi-writer-semantics-for-issue-38/verification-attempts.json index 60fc7b2..edfbdec 100644 --- a/.specsync/changes/CHG-0016-loud-torn-filestate-reads-and-document-multi-writer-semantics-for-issue-38/verification-attempts.json +++ b/.specsync/changes/CHG-0016-loud-torn-filestate-reads-and-document-multi-writer-semantics-for-issue-38/verification-attempts.json @@ -180,6 +180,26 @@ "REQ-state-store-004", "REQ-state-store-015" ] + }, + { + "timestamp": 1784440606, + "commit": "384c2b26692a3020dbbeeabece9e45f4cf4d7c3d", + "contract_digest": "2ce0d16b39555da39844a51a6ba4f24ee62c65751cf41af3d664905ba89b4158", + "workspace_digest": "64c0bcba0fac8f0ef02f10f4b1851cff103ae5c2929a3e0853b581a88afb8daa", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-005", + "REQ-aps-cli-017", + "REQ-state-store-004", + "REQ-state-store-015" + ] } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json index d8cd4ce..a6618c8 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/approvals.json @@ -34,6 +34,13 @@ "timestamp": 1784438805, "digest": "73f13914de5b3b98580d7b136e930d66bedc03f783c6b94c10ee6f210fca1f89", "note": null + }, + { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784440646, + "digest": "1fcdfd92d32fe9fe4d9086236a402588573cf244cb4275852e392b064e86d493", + "note": null } ], "reopenings": [ @@ -549,6 +556,177 @@ }, "stale_acceptance_input_digest": "0ce28119d82d404a3f9ef802ec7a6a1999d2d30089aca6d9715bbce544228a1f", "current_acceptance_input_digest": "00061dbf1a476d0ce19ea1b4fc404dc9c110f1e9e45171bf83ac5bf40a8f7e68" + }, + { + "schema_version": 1, + "change_id": "CHG-0021-watch-signal-handling-and-termination-semantics-issue-34", + "actor": "0xLeif", + "reason": "Signal source lifetime fix and SIGINT smoke restore; rebinding evidence", + "timestamp": 1784440622, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "0xLeif", + "timestamp": 1784438805, + "digest": "73f13914de5b3b98580d7b136e930d66bedc03f783c6b94c10ee6f210fca1f89", + "note": null + }, + "prior_verification": { + "timestamp": 1784438798, + "commit": "d39cd25e05dd197f80ae835b5baba39a6917db2f", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "8fcaebc50225b5f1bce4d95e06ff1192587cf1ce767813f3b550adbd09495178", + "acceptance_input_digest": "00061dbf1a476d0ce19ea1b4fc404dc9c110f1e9e45171bf83ac5bf40a8f7e68", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "bd09a0ace8b7d37cfc0b686ab0ff2d3094fdb015cbb2cd09e6e6a5db6961439f", + "entry_digest": "e9f52413e2cae8ce5dcf4d5ebfb03b873d245731b80c59d18d3c9cc3c19c009f", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "1adbf9dab00c1129b9e50c355a9098c75ec4b534af048e8a953c9037c8e3f9cb", + "entry_digest": "bf24557a813a8fdfb925a55ebeeeb2e49998c0ec741883ed08740cd5b2993d0b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "da18c7227ca19b2a29b2ee90d6e4056cb6ab803a4aea5528d4c6af6c1daa2842", + "entry_digest": "c95bfcb30a4031b51c42319075bceceae03660b7d019b414cce45446895f4700", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "55d23aa11c362f6e3b2c22602a546170746bb01f0d7dbe60f24e65574ab4f333", + "entry_digest": "8bb8642ad9a663b25ec22120e629c14cb6d720a581db1700d23ed9ec78beff53", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "722fad1be75f8e8837149336d06f3683eaefbdaec50f8f1677a1c6464777b6cb", + "entry_digest": "760d0555b843081447ded1250666b635d4223b64632faccf35bc4ed2afeadbe0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/WatchTermination.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "d68fcc1d7c8f5268d2d46003459c356e0b68aa16dd8412177b11eb4d744acf9f", + "entry_digest": "4190521ffcec836800af83d92de4569d88b056ba264992b8e2346786228bd01e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "dfaba191724faf87fceea73e18f86bf4b337ba7b4c2c0465bf6e3ba2ee7d41f2", + "entry_digest": "ce446ea3ecb252eaa282af37010a788652b5883f987c92e4aa72084fdc98fe65", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli", + "kind": "non_file", + "mode": 0, + "payload_digest": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "entry_digest": "6f7c83bad496153e7b31ed762f67bd1c75394999419522d3535142c1e5746b58", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "85bf511e7d8be12a267df3a5ac7a6404752a3e03242eccb3f7e85c1e17a06fcc", + "entry_digest": "ee28b08a89dd5dd58f680ea6f755b3607fb93616ac7c3dae820815b01b40b845", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "7304a6233da2051242aeac63a51197aaf6f92d936692fe8655801eff842a2fd0", + "entry_digest": "e78ad0996c0135c37d41afa37c5fda13901bb63e14f00c3208896f7b104a72e8", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ac4beecaea165e5e95ab606d82277475fc2d7a6cb5611b0272a8d3dd34f9dc64", + "entry_digest": "14a0593d575717adb53656117358c3a36e4b48f9235b1a6d139deab58f158c04", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ca6cafd22fb3c803eed3dc94cb980d8f535d51e0fd289b46bae82b7f255377de", + "entry_digest": "c349b8f073cefe7e387f8fabdd4dada3830be59b4b6d22a4ce79b2c29399c70e", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ee5ae7f63536e2089a2b51887ed62b8392561052cdaa37c29707e7a10e2136bd", + "entry_digest": "daf1e496e836c46da31eb6ac679b352d7bf615536595be310953eb0ae9a36fd4", + "owners": [ + "aps-cli" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] + }, + "stale_acceptance_input_digest": "00061dbf1a476d0ce19ea1b4fc404dc9c110f1e9e45171bf83ac5bf40a8f7e68", + "current_acceptance_input_digest": "ad20b2bc20637a204981557563da2f88c654721ce352483e82ac4f035672f488" } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json index b76f9d1..ac85a9a 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/state.json @@ -9,7 +9,7 @@ "canonical_applied": true, "base_commit": "c4ecd60b2a46bca4780f1b4af79a1d495d646287", "created_at": 1784424255, - "updated_at": 1784438805, + "updated_at": 1784440646, "affected_specs": [ "aps-cli" ], diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json index bee200d..d4bb0a9 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification-attempts.json @@ -68,6 +68,23 @@ "requirement_ids": [ "REQ-aps-cli-012" ] + }, + { + "timestamp": 1784440635, + "commit": "384c2b26692a3020dbbeeabece9e45f4cf4d7c3d", + "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", + "workspace_digest": "64c0bcba0fac8f0ef02f10f4b1851cff103ae5c2929a3e0853b581a88afb8daa", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-012" + ] } ] } diff --git a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json index 9b00c18..d980042 100644 --- a/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json +++ b/.specsync/changes/CHG-0023-watch-signal-handling-and-termination-semantics-issue-34/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1784438798, - "commit": "d39cd25e05dd197f80ae835b5baba39a6917db2f", + "timestamp": 1784440635, + "commit": "384c2b26692a3020dbbeeabece9e45f4cf4d7c3d", "contract_digest": "4b649d7aa4143782bcc58f83e17934f8dedfb0b944feb90b3fdaf9e4793b6e8a", - "workspace_digest": "8fcaebc50225b5f1bce4d95e06ff1192587cf1ce767813f3b550adbd09495178", - "acceptance_input_digest": "00061dbf1a476d0ce19ea1b4fc404dc9c110f1e9e45171bf83ac5bf40a8f7e68", + "workspace_digest": "64c0bcba0fac8f0ef02f10f4b1851cff103ae5c2929a3e0853b581a88afb8daa", + "acceptance_input_digest": "ad20b2bc20637a204981557563da2f88c654721ce352483e82ac4f035672f488", "acceptance_manifest": { "schema_version": 1, "entries": [ @@ -31,8 +31,8 @@ "path": "Scripts/smoke.sh", "kind": "file", "mode": 33261, - "payload_digest": "da18c7227ca19b2a29b2ee90d6e4056cb6ab803a4aea5528d4c6af6c1daa2842", - "entry_digest": "c95bfcb30a4031b51c42319075bceceae03660b7d019b414cce45446895f4700", + "payload_digest": "72d0ba5895f18111db528806dd045bca171e76b527de302d72e8f686f3796816", + "entry_digest": "bd08ed8f2c9e93e20d8c3e6075119e820595bfb62b57b0c6d6c822ced28c59c7", "owners": [ "@exact:delivery" ] @@ -41,8 +41,8 @@ "path": "Sources/aps/Aps.swift", "kind": "file", "mode": 33188, - "payload_digest": "55d23aa11c362f6e3b2c22602a546170746bb01f0d7dbe60f24e65574ab4f333", - "entry_digest": "8bb8642ad9a663b25ec22120e629c14cb6d720a581db1700d23ed9ec78beff53", + "payload_digest": "1c4eadc926957763137a2a0f279122ba0a59ff7ee5700aee8f22a8e1a8f56b05", + "entry_digest": "416a9732533564e60e4aa9b60e16a52bc5fb7b24e8140a19a41827635280b6a9", "owners": [ "aps-cli" ] diff --git a/Scripts/smoke.sh b/Scripts/smoke.sh index e705ca8..7e768c6 100755 --- a/Scripts/smoke.sh +++ b/Scripts/smoke.sh @@ -140,9 +140,7 @@ echo "$out" | grep -q '"reason":"timeout"' "$bin" watch counter --jsonl > "$APS_HOME/watch.out" 2>/dev/null & WPID=$! sleep 1 -# SIGTERM: the GitHub runner co-opts SIGINT for job cancellation and masks it -# in job children; TERM exercises the same handler path and exit semantics. -kill -TERM $WPID +kill -INT $WPID # Bounded wait: the signal must stop the watch within a few seconds. RC=0 for _ in $(seq 1 20); do @@ -155,8 +153,8 @@ if kill -0 $WPID 2>/dev/null; then exit 1 fi wait $WPID || RC=$? -test "${RC:-0}" -eq 143 || { echo "expected exit 143 for SIGTERM, got ${RC:-0}" >&2; exit 1; } -grep -q '"reason":"sigterm"' "$APS_HOME/watch.out" +test "${RC:-0}" -eq 130 || { echo "expected exit 130 for SIGINT, got ${RC:-0}" >&2; exit 1; } +grep -q '"reason":"sigint"' "$APS_HOME/watch.out" if grep -vq '^{' "$APS_HOME/watch.out"; then echo "jsonl stream must contain only JSON lines" >&2 exit 1 diff --git a/Sources/aps/Aps.swift b/Sources/aps/Aps.swift index dc398d8..f0bb243 100644 --- a/Sources/aps/Aps.swift +++ b/Sources/aps/Aps.swift @@ -146,7 +146,6 @@ extension Aps { let signalBox = SignalBox() let signalSources = installWatchSignalHandlers(signalBox) - _ = signalSources // retained for the watch lifetime if count == nil && timeout == nil { CLIOutput.writeError("watch: unbounded stream; press Ctrl-C to stop, or use --count/--timeout for bounded runs") @@ -229,6 +228,11 @@ extension Aps { throw ExitCode(stopReason.exitCode) } } + + // Actual use after the loop: guarantees the dispatch sources + // outlive the watch (debug kept them incidentally; release + // optimized them away and silently lost every signal). + signalSources.forEach { $0.cancel() } } } } From 030296685dd1b4a171304e41dcfc29ac0beb518f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 13:00:38 +0000 Subject: [PATCH 7/7] Fix: remove duplicate CLIOutput.writeError after watch rebase Co-authored-by: Leif --- Sources/aps/CLIOutput.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Sources/aps/CLIOutput.swift b/Sources/aps/CLIOutput.swift index 949c68d..fbbf6aa 100644 --- a/Sources/aps/CLIOutput.swift +++ b/Sources/aps/CLIOutput.swift @@ -191,12 +191,6 @@ enum CLIOutput { } } - static func writeError(_ line: String) { - if let data = (line + "\n").data(using: .utf8) { - FileHandle.standardError.write(data) - } - } - @MainActor static func typedValue(for key: DemoKey, store: StateStore) throws -> JSONValue { try typedValue(for: key, from: store.get(key))