Skip to content

Commit a57d961

Browse files
authored
Io capture (#46)
2 parents d079719 + a4aa676 commit a57d961

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+3949
-222
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,36 @@ All subclasses carry the same attributes, so existing handlers can migrate by ca
4848

4949
Pass `--codetracer-json-errors` (or set the policy via `configure_policy(json_errors=True)`) to stream a one-line JSON trailer on stderr. The payload includes `run_id`, `trace_id`, `error_code`, `error_kind`, `message`, and the `context` map so downstream tooling can log failures without scraping text.
5050

51+
### IO capture configuration
52+
53+
Line-aware capture (see [ADR 0008](design-docs/adr/0008-line-aware-io-capture.md)) installs `LineAwareStdout`, `LineAwareStderr`, and `LineAwareStdin` proxies so every chunk carries `{path_id, line, frame_id}` metadata. The proxies forward writes immediately to keep TTY behaviour unchanged and the batching sink emits newline/flush/step-delimited chunks. When the FD mirror fallback observes bytes that bypassed the proxies, the resulting `IoChunk` carries the `mirror` flag so downstream tooling can highlight native writers separately. Recorder logs and telemetry use `ScopedMuteIoCapture` to avoid recursive capture.
54+
55+
Control the feature through the policy layer:
56+
57+
- CLI: `python -m codetracer_python_recorder --io-capture=off script.py` disables capture, while `--io-capture=proxies+fd` also mirrors raw file-descriptor writes.
58+
- Python: `configure_policy(io_capture_line_proxies=False)` toggles proxies, and `configure_policy(io_capture_fd_fallback=True)` enables the FD fallback.
59+
- Environment: set `CODETRACER_CAPTURE_IO=off`, `proxies`, or `proxies+fd` (`,` is also accepted) to match the CLI and Python helpers.
60+
61+
Manual smoke check: `python -m codetracer_python_recorder examples/stdout_script.py` should report the proxied output while leaving the console live.
62+
63+
#### Troubleshooting replaced stdout/stderr
64+
65+
Third-party tooling occasionally replaces `sys.stdout` / `sys.stderr` after the proxies install. When that happens, IO metadata stops updating and the recorder falls back to passthrough behaviour. You can verify the binding at runtime:
66+
67+
```python
68+
import sys
69+
from codetracer_python_recorder.runtime import LineAwareStdout, LineAwareStderr
70+
71+
print(type(sys.stdout).__name__, isinstance(sys.stdout, LineAwareStdout))
72+
print(type(sys.stderr).__name__, isinstance(sys.stderr, LineAwareStderr))
73+
```
74+
75+
Both `isinstance` checks should return `True`. If they do not:
76+
77+
1. Re-run `configure_policy(io_capture_line_proxies=True)` (or restart tracing) to reinstall the proxies before the other tool mutates the streams.
78+
2. Fall back to FD mirroring by enabling `CODETRACER_CAPTURE_IO=proxies+fd` so native writes still reach the ledger-backed mirror.
79+
3. As a last resort, disable IO capture (`--io-capture=off`) and rely on console output while investigating the conflicting integration.
80+
5181
### Migration checklist for downstream tools
5282

5383
- Catch `RecorderError` (or a subclass) instead of `RuntimeError`.

codetracer-python-recorder/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
66

77
## [Unreleased]
88
### Added
9+
- Introduced a line-aware IO capture pipeline that records stdout/stderr chunks with `{path_id, line, frame_id}` attribution via the shared `LineSnapshotStore` and multi-threaded `IoEventSink`.
10+
- Added `LineAwareStdout`, `LineAwareStderr`, and `LineAwareStdin` proxies that forward to the original streams while batching writes on newline, explicit `flush()`, 5 ms idle gaps, and step boundaries.
11+
- Added policy, CLI, and environment toggles for IO capture (`--io-capture`, `configure_policy(io_capture_line_proxies=..., io_capture_fd_fallback=...)`, `CODETRACER_CAPTURE_IO`) alongside the `ScopedMuteIoCapture` guard that suppresses recursive recorder logging.
12+
- Added an optional FD mirror fallback that duplicates `stdout`/`stderr`, diffs native writes against the proxy ledger, emits `mirror`-flagged `IoChunk`s, and restores descriptors on teardown.
13+
- Documented IO capture behaviour in the README with ADR 0008 context, manual smoke instructions, and troubleshooting steps for replaced `sys.stdout` / `sys.stderr`.
914
- Documented the error-handling policy in the README, including the `RecorderError` hierarchy, policy hooks, JSON error trailers, exit codes, and sample handlers for structured failures.
1015
- Added an onboarding guide at `docs/onboarding/error-handling.md` with migration steps for downstream tools.
1116
- Added contributor guidance for assertions: prefer `bug!` / `ensure_internal!` over `panic!` / `.unwrap()`, and pair `debug_assert!` with classified errors.

codetracer-python-recorder/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codetracer-python-recorder/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ serde = { version = "1.0", features = ["derive"] }
3131
serde_json = "1.0"
3232
uuid = { version = "1.10", features = ["v4"] }
3333
recorder-errors = { version = "0.1.0", path = "crates/recorder-errors" }
34+
libc = "0.2"
3435

3536
[dev-dependencies]
3637
pyo3 = { version = "0.25.1", features = ["auto-initialize"] }

codetracer-python-recorder/codetracer_python_recorder/cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ def _parse_args(argv: Sequence[str]) -> RecorderCLIConfig:
9797
action="store_true",
9898
help="Emit JSON error trailers on stderr.",
9999
)
100+
parser.add_argument(
101+
"--io-capture",
102+
choices=["off", "proxies", "proxies+fd"],
103+
help=(
104+
"Control stdout/stderr capture. Without this flag, line-aware proxies stay enabled. "
105+
"'off' disables capture, 'proxies' forces proxies without FD mirroring, "
106+
"'proxies+fd' also mirrors raw file-descriptor writes."
107+
),
108+
)
100109

101110
known, remainder = parser.parse_known_args(argv)
102111
pending: list[str] = list(remainder)
@@ -145,6 +154,19 @@ def _parse_args(argv: Sequence[str]) -> RecorderCLIConfig:
145154
policy["log_file"] = Path(known.log_file).expanduser().resolve()
146155
if known.json_errors:
147156
policy["json_errors"] = True
157+
if known.io_capture:
158+
match known.io_capture:
159+
case "off":
160+
policy["io_capture_line_proxies"] = False
161+
policy["io_capture_fd_fallback"] = False
162+
case "proxies":
163+
policy["io_capture_line_proxies"] = True
164+
policy["io_capture_fd_fallback"] = False
165+
case "proxies+fd":
166+
policy["io_capture_line_proxies"] = True
167+
policy["io_capture_fd_fallback"] = True
168+
case other: # pragma: no cover - argparse choices block this
169+
parser.error(f"unsupported io-capture mode '{other}'")
148170

149171
return RecorderCLIConfig(
150172
trace_dir=trace_dir,

codetracer-python-recorder/src/ffi.rs

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,9 @@ mod tests {
145145
#[test]
146146
fn map_recorder_error_sets_python_attributes() {
147147
Python::with_gil(|py| {
148-
let err = usage!(
149-
ErrorCode::UnsupportedFormat,
150-
"invalid trace format"
151-
)
152-
.with_context("format", "yaml")
153-
.with_source(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
148+
let err = usage!(ErrorCode::UnsupportedFormat, "invalid trace format")
149+
.with_context("format", "yaml")
150+
.with_source(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
154151
let pyerr = map_recorder_error(err);
155152
let ty = pyerr.get_type(py);
156153
assert!(ty.is(py.get_type::<PyUsageError>()));
@@ -191,9 +188,8 @@ mod tests {
191188
#[test]
192189
fn dispatch_converts_recorder_error_to_pyerr() {
193190
Python::with_gil(|py| {
194-
let result: PyResult<()> = dispatch("dispatch_env", || {
195-
Err(enverr!(ErrorCode::Io, "disk full"))
196-
});
191+
let result: PyResult<()> =
192+
dispatch("dispatch_env", || Err(enverr!(ErrorCode::Io, "disk full")));
197193
let err = result.expect_err("expected PyErr");
198194
let ty = err.get_type(py);
199195
assert!(ty.is(py.get_type::<PyEnvironmentError>()));

0 commit comments

Comments
 (0)