Skip to content

feat: week-1 scaffold + Lane A (gRPC stubs, go-plugin handshake) - #1

Merged
devarismeroxa merged 1 commit into
mainfrom
feat/week1-scaffold-lane-a
Jul 23, 2026
Merged

feat: week-1 scaffold + Lane A (gRPC stubs, go-plugin handshake)#1
devarismeroxa merged 1 commit into
mainfrom
feat/week1-scaffold-lane-a

Conversation

@devarismeroxa

Copy link
Copy Markdown
Contributor

Initial scaffold of the Python connector SDK — v0.19 anchor build, the strategic #2 SDK (Go → Python → Rust → TS).

Scope (week-1 foundation only)

Repo skeleton + Lane A. The full Source/Destination lifecycle, config/schema, acceptance-test harness, --lang python scaffolding wiring, and the example connector land in follow-up PRs ahead of the week-3 Tier-1 sign-off.

What's here

  • Lane A: vendored protocol-v2 gRPC stubs (SourcePlugin/DestinationPlugin/SpecifierPlugin + opencdc/config types), generated via buf against BSR, v1 excluded; the go-plugin handshake (magic-cookie check, v2-only version negotiation, exact pipe-delimited stdout line) — 18 unit tests, green locally.
  • Repo scaffold: pyproject.toml (uv/hatchling, ruff + mypy strict), CI workflows (lint/test/compat-nightly/release), Apache-2.0, design doc at docs/design/.

Review notes (folded from the plan review)

  • Both load-bearing conduit-connector-sdk Go citations (destination.go:345-350, source.go:270-295) re-verified as-read before the invariant-1/4 parity argument was relied on.
  • AC3 shutdown test is a deterministic Shutdown-RPC-invocation assertion, not a timing/log heuristic.
  • Added failure mode: hung/deadlocked asyncio loop (no Go analog) → mitigated by a signal-installed watchdog with a bounded force-kill deadline independent of the loop.

Open (non-blocking)

  • Packaging for the clean-exec/no-PATH constraint (PyInstaller vs zipapp vs wrapper).
  • Low-probability sys.path module-name collision in generated stubs (flagged in-file).
  • PyPI name conduit-connector-sdk — squat-check before first publish.

Risk tier

Tier 1 (connector SDK — ack/position semantics cross the gRPC boundary). This scaffold establishes the baseline; the Tier-1 human sign-off gate is the completed SDK core (week 3), not this seed.

🤖 Generated with Claude Code
https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

Bootstraps ConduitIO/conduit-connector-sdk-python for the v0.19 Python
connector SDK workstream (pulled forward per DeVaris's explicit override,
Tier 1 / data path per CLAUDE.md).

- Repo scaffold: pyproject.toml (uv/hatchling), ruff+mypy config, CI
  workflow stubs (lint/test/compat-nightly/release), LICENSE, README,
  CONTRIBUTING, CHANGELOG.
- Design doc landed at docs/design/20260707-python-connector-sdk.md,
  adapted from the reviewed ConduitIO/conduit design doc with three
  build-review must-fixes folded in: (1) re-verified the two Go-source
  citations underpinning the invariant-1/3/4 parity argument
  (destination.go:345-350, source.go:270-295 — both confirmed correct
  against conduit-connector-sdk); (2) tightened the Phase-1 shutdown
  acceptance test from a timing/log heuristic to a deterministic
  GRPCController.Shutdown RPC-invocation assertion; (3) added the
  hung/deadlocked-asyncio-event-loop-mid-write failure mode (no Go analog,
  since goroutines are preemptible and asyncio signal handlers are not),
  with a bounded SDK-side watchdog force-kill deadline as mitigation.
- Lane A: vendored gRPC/protobuf stubs for conduit-connector-protocol v2
  (SourcePlugin/DestinationPlugin/SpecifierPlugin) + the opencdc/config
  message types from conduit-commons, generated via buf against BSR and
  scoped with --path to exclude the deprecated v1 protocol and unrelated
  commons surfaces (tools/generate-stubs.sh is the single source of truth
  for the regen command).
- Lane A: go-plugin handshake implementation (_handshake.py) — magic
  cookie check, protocol version negotiation (v2 only), and the exact
  pipe-delimited stdout handshake line, unit-tested against the documented
  wire format independent of a running gRPC server.

Gates run locally: package builds/imports, pytest 18/18 green, ruff
format+lint clean, mypy --strict clean. Not run: acceptance suite, real
Conduit launch, chaos/SIGTERM tests — all later lanes, not week-1 scope.

No GitHub repo created — DeVaris to create ConduitIO/conduit-connector-sdk-python.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
@devarismeroxa
devarismeroxa merged commit d9f776f into main Jul 23, 2026
8 checks passed
devarismeroxa added a commit that referenced this pull request Jul 23, 2026
DX audit fix #1. `datetime.timedelta`-typed BaseConfig fields now map to
config.Parameter.TYPE_DURATION instead of raising NotImplementedError:

- New public conduit.config.format_go_duration()/parse_go_duration():
  serialize/parse Go's time.Duration.String()/ParseDuration syntax
  ("5s", "1h30m", "500ms", "1.5h", "-1.5h", ns/us/µs/ms/s/m/h units),
  using exact fractions.Fraction arithmetic (no float rounding) down to
  microsecond resolution -- the finest datetime.timedelta supports.
- to_parameters() serializes a timedelta field's default via
  format_go_duration(); gt=/lt=/ge=/le= constraints on duration fields
  use the same exact (not epsilon-approximated) ±1-microsecond boundary
  adjustment already used for int fields, since timedelta is likewise a
  discrete, integer-resolution type.
- BaseConfig gained a model_validator(mode="before") that parses a
  Go-duration string into a real timedelta before pydantic's own
  validation runs, so the Configure RPC's map<string,string> config
  values round-trip correctly. Direct construction with a real timedelta
  still works unchanged.
- TYPE_EXCLUSION remains an open, documented A-gap (untouched by this
  fix) -- still raises NotImplementedError rather than guessing.

Tests: tests/test_go_duration.py (known-Go-output pins + Hypothesis
round-trip property over arbitrary microsecond counts + malformed-syntax
rejection), tests/test_config.py (to_parameters() mapping + Configure-side
string parsing + direct-construction-still-works cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
devarismeroxa added a commit that referenced this pull request Jul 23, 2026
…bug found by it

DX audit fix #5 (the #1 lever per the audit).

New `conduit-connector-sdk` console script, `build` subcommand
(_build.py/_cli.py): packages a connector project into one self-contained,
directly-executable artifact. Closes the design doc §1.1.6 packaging gap
-- Conduit execs a standalone connector subprocess with a clean
environment (no inherited PATH), so a `pip install`-then-shebang-script
connector cannot launch; this command produces a file whose shebang is an
absolute interpreter path resolved at build time.

Vendoring strategy: copies files from the *current environment's*
already-installed distributions (importlib.metadata: file manifests +
transitive Requires-Dist, marker-filtered for extras) rather than a fresh
pip install -- this SDK isn't published to PyPI yet, so a fresh resolve
would fail outright; this also means `build` needs no network access and
runs in ~0.2-0.4s. conduit-connector-sdk itself is vendored by copying its
actual installed location directly (works for editable dev installs too,
where importlib.metadata's file manifest is just a .pth redirect).

Not a plain zipapp: grpcio and pydantic's pydantic-core both ship compiled
extensions, which zipimport cannot load from inside a zip archive. Found
this the hard way -- an earlier version of this command raised BuildError
on any compiled extension, which would have made this SDK's own core
dependencies (grpcio, pydantic) impossible to vendor at all. Fixed by
generating a small, dependency-free bootstrap __main__.py (the only thing
zipimport ever runs directly) that extracts the real payload to a
per-build cache directory on first run -- the same fundamental approach
shiv/pex use -- then executes the connector's real entry point from those
extracted, real files. Verified end-to-end: built the example connector,
exec'd the artifact directly (not `python <artifact>`), confirmed
grpc/pydantic-core's .so files are present as real extracted files and
the handshake completes correctly.

Bug fix, found via that same end-to-end testing: sending a real SIGTERM
to a running serve() process whose connector's teardown() raised (e.g.
HTTPPollSource.teardown() accessing self._client before open() ever ran)
silently hung for the full watchdog deadline instead of shutting down
promptly. Root cause: _sigterm_shutdown()'s coroutine is scheduled via
run_coroutine_threadsafe from a signal handler with its Future never
awaited/checked, so an exception inside it was silently swallowed and
shutdown_requested was never set. Fixed: shutdown_requested.set() now runs
unconditionally in a finally block, with a clear stderr diagnostic
distinguishing "teardown raised" from "loop genuinely wedged" -- a buggy
teardown() no longer blocks the entire SIGTERM-triggered graceful path.
Also hardened the example connector's own teardown() to guard against
running before open() ever did.

Tests: tests/test_build.py builds the real example connector and execs
the resulting artifact directly as a subprocess (mirroring exactly how
Conduit's dispenser launches a plugin) -- asserts a valid handshake line,
an absolute-path shebang, compiled extensions present as real extracted
files, cache reuse on a second launch, and (the regression test for the
bug above) that a real SIGTERM triggers prompt graceful shutdown rather
than hanging until the watchdog fires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD
devarismeroxa added a commit that referenced this pull request Jul 24, 2026
…, acceptance harness + example (#2)

* feat(record,config): OpenCDC record model + pydantic config introspection

Lane C of the v0.19 Python connector SDK workstream (docs/design/20260707-python-connector-sdk.md).

- conduit/record.py: Record/Change/Operation/Data (bytes | Mapping) per §2.3,
  plus Metadata well-known-key constants and a representative set of typed
  accessors (created_at/read_at/collection).
- conduit/config.py: BaseConfig (pydantic v2) + Field re-export +
  to_parameters() introspecting model_fields into config.Parameter/Validation
  (no codegen, per §2.2). gt=/lt= map exactly; ge=/le= are approximated as
  exclusive gt/lt (exact for int fields, epsilon-nudged for float, both
  documented). Literal[...] -> one TYPE_INCLUSION per value. TYPE_DURATION
  and TYPE_EXCLUSION explicitly raise NotImplementedError (open A-gaps, not
  silently guessed).
- conduit/errors.py: BackoffRetry/BatchWriteError/ConnectorError.
  BatchWriteError is the B1 data-loss fix (§2.5): construction requires an
  exhaustive, disjoint success/failures accounting (or a written= prefix);
  ValueError at construction time if incomplete -- "ack everything not
  explicitly marked failed" is structurally unrepresentable, not just
  documented.
- pyproject.toml: mypy_path/overrides extended so the generated
  connector.v2/config.v1/opencdc.v1 stubs (reached via conduit._grpc's
  sys.path trick, not conduit._grpc.* dotted imports) resolve under mypy;
  known-first-party isort config so `import conduit._grpc` always sorts
  before those stub imports (verified: reordering it breaks the sys.path
  side effect at runtime, not just a style nit).
- tests/test_record_codec.py: Hypothesis round-trip tests, including the B3
  google.protobuf.Struct int->float precision-loss case pinned exactly (not
  papered over with `==` laxity).
- tests/test_config.py, tests/test_errors.py: mapping-rule and
  BatchWriteError construction-validation coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

* feat(source,destination,serve): Source/Destination lifecycle over gRPC v2 + shutdown watchdog

Lane B of the v0.19 Python connector SDK workstream (docs/design/20260707-python-connector-sdk.md).

- conduit/_dispatch.py: dual sync/async method dispatch (inspect.iscoroutinefunction
  detection, sync overrides run in the default thread-pool executor) shared
  by Source and Destination, per §2.1.
- conduit/_introspect.py: shared generic-parameter recovery
  (Source[Config]/Destination[Config] -> Config) used to validate Configure's
  config map and build Specify's parameter map without author boilerplate.
- conduit/source.py: Source ABC (abc.ABC, read() abstract) + _SourceServicer
  adapting it to SourcePluginServicer. Read loop reuses the Go SDK's exact
  backoff constants (Factor=2, Min=100ms, Max=5s, source.go:270-295,
  re-verified MUST-FIX 1) as a single serial loop. Invariant 1: ack() is
  called only from _consume_acks, driven only by Conduit's ack_positions on
  the Run request stream -- the read loop itself never calls ack().
- conduit/destination.py: Destination ABC (write() abstract) +
  _DestinationServicer. _write_batch is the B1 enforcement site: full
  success only after write() returns cleanly; BatchWriteError's
  already-validated success/failures accounting drives every ack/nack
  decision; any other exception nacks the entire batch. No code path acks
  an index absent from the exhaustive success set.
- conduit/_grpc/_controller.py: hand-written (not generated -- go-plugin's
  own internal proto, outside conduit-connector-protocol) GRPCController.Shutdown
  service, registered via grpc.method_handlers_generic_handler.
- conduit/_grpc/adapters.py: Record/Data/Change <-> proto conversion. The B3
  google.protobuf.Struct int->float boundary is documented at its one exact
  call site (_data_to_proto).
- conduit/serve.py: serve() entry point -- handshake validation (reusing
  _handshake.py, Lane A), grpc.aio server bootstrap, health/specifier/
  connector servicer registration, and _ShutdownCoordinator: the
  hung-event-loop watchdog (MUST-FIX 3). SIGTERM is caught with low-level
  signal.signal (not loop.add_signal_handler, which cannot fire if the loop
  is wedged) to start an independent threading.Timer that force-exits after
  a bounded, configurable deadline if graceful shutdown (teardown() +
  GRPCController.Shutdown) hasn't confirmed completion.
- tests/test_source.py, tests/test_destination.py: ack-ordering and B1
  partial-batch-nack coverage (test_destination_partial_write_nacks_all's
  three cases: incomplete accounting, well-formed written= prefix,
  non-BatchWriteError exception).
- tests/test_serve.py: the deterministic shutdown test (MUST-FIX 2) -- a
  real grpc.aio server, a real gRPC client calling
  /plugin.GRPCController/Shutdown, asserting the RPC succeeds and teardown()
  ran exactly once beforehand via a spy, not a timing race -- plus the
  hung-loop watchdog tests (MUST-FIX 3), including one that genuinely wedges
  a real event loop in a background thread and confirms the watchdog still
  fires within its documented deadline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

* feat(testing): acceptance-test harness + worked http-poll-source example

Lane D of the v0.19 Python connector SDK workstream (docs/design/20260707-python-connector-sdk.md).

- conduit/testing/acceptance.py: AcceptanceTestDriver Protocol,
  ConfigurableAcceptanceTestDriver convenience wrapper, and
  AcceptanceTestSuite -- the versioned (CONTRACT_VERSION = "2026-07.v1")
  acceptance suite an author subclasses in their own pytest module. Covers
  every category from the design doc §3: specifier existence/validity,
  config validation (success + required-param-missing), resume-at-position
  (snapshot and CDC-equivalent), read/write round trip, read timeout
  behavior, and partial-batch write correctness (paralleling
  test_destination_partial_write_nacks_all as an SDK-level guarantee).
  Exercises connectors in-process via the same servicer adapters serve.py
  uses -- no real gRPC socket, no real Conduit binary (that's
  compat-nightly.yml/Conduit-repo scope).
- conduit/testing/fixtures.py: golden OpenCDC record-shape factories
  (snapshot/create/update/delete_record, with_collection).
- examples/http-poll-source/main.py: the design doc §2.7 worked example,
  made fully runnable (httpx-based HTTP polling source, BackoffRetry on
  empty responses, position-based resume).
- examples/http-poll-source/pyproject.toml: standalone packaging, mirroring
  what `conduit connector new --lang python` will scaffold (Phase 3).
- tests/test_acceptance_harness.py: the suite run against a synthetic
  in-memory driver.
- tests/test_example_http_poll_source.py: the suite run against the real,
  unmodified example file, in-process against a real local HTTP server
  (stdlib http.server, no httpx mocking) -- this test passes, it is not
  skipped or stubbed.
- README/CHANGELOG updates reflecting Lanes B/C/D landing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

* fix(ci): pin types-protobuf dev dep + reformat design doc's embedded code

CI (uv sync --all-extras, ruff/mypy jobs) caught two gaps not visible in my
local dev environment, whose packages had drifted from what a fresh
`pip install -e '.[dev]'`/`uv sync` resolves:

- mypy: `types-protobuf` was installed manually in my local venv while
  developing but never added to pyproject.toml's dev extras, so a fresh
  environment (CI, or any contributor running `uv sync`) hit "Library stubs
  not installed for google.protobuf" in serve.py/_grpc/_controller.py.
  Added `types-protobuf>=6.30,<7` to `[project.optional-dependencies].dev`.
- ruff format: ruff 0.16.0 (resolved by CI's `ruff>=0.14,<1` constraint;
  0.15.x, what I had locally, treats Markdown formatting as
  preview-only and skips it by default) now formats Markdown-embedded
  Python code fences by default. This caught 3 pre-existing, purely
  cosmetic whitespace inconsistencies in
  docs/design/20260707-python-connector-sdk.md's embedded code examples
  (double-space-before-comment, missing blank lines) -- not present in any
  file this PR otherwise touches, not a content change, verified via `git
  diff` to be whitespace-only.

Verified by simulating CI exactly: a fresh venv, `pip install -e '.[dev]'`
(uv unavailable in this sandbox), then ruff format --check / ruff check /
mypy / pytest -- all green, matching CI's actual dependency resolution
rather than my possibly-stale local venv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

* feat(config): Go-duration support for timedelta config fields

DX audit fix #1. `datetime.timedelta`-typed BaseConfig fields now map to
config.Parameter.TYPE_DURATION instead of raising NotImplementedError:

- New public conduit.config.format_go_duration()/parse_go_duration():
  serialize/parse Go's time.Duration.String()/ParseDuration syntax
  ("5s", "1h30m", "500ms", "1.5h", "-1.5h", ns/us/µs/ms/s/m/h units),
  using exact fractions.Fraction arithmetic (no float rounding) down to
  microsecond resolution -- the finest datetime.timedelta supports.
- to_parameters() serializes a timedelta field's default via
  format_go_duration(); gt=/lt=/ge=/le= constraints on duration fields
  use the same exact (not epsilon-approximated) ±1-microsecond boundary
  adjustment already used for int fields, since timedelta is likewise a
  discrete, integer-resolution type.
- BaseConfig gained a model_validator(mode="before") that parses a
  Go-duration string into a real timedelta before pydantic's own
  validation runs, so the Configure RPC's map<string,string> config
  values round-trip correctly. Direct construction with a real timedelta
  still works unchanged.
- TYPE_EXCLUSION remains an open, documented A-gap (untouched by this
  fix) -- still raises NotImplementedError rather than guessing.

Tests: tests/test_go_duration.py (known-Go-output pins + Hypothesis
round-trip property over arbitrary microsecond counts + malformed-syntax
rejection), tests/test_config.py (to_parameters() mapping + Configure-side
string parsing + direct-construction-still-works cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

* feat(source,destination): rename lifecycle hooks, clarify ack(), forward validation detail

DX audit fixes #2-#4.

- Source.ack()'s default was already a genuine no-op (return None, no log
  line, no raise) -- verified, and its docstring now says explicitly:
  "you don't need to override ack() unless you're acknowledging against
  the source system itself (e.g. committing a Kafka offset, deleting a
  queue message)." Same note added to the example connector's README.
- Renamed lifecycle_on_created/lifecycle_on_updated/lifecycle_on_deleted
  to on_created/on_updated/on_deleted on both Source and Destination --
  the lifecycle_ prefix was redundant (these already live on the
  connector class). Updated the ABCs, the servicer dispatch code, and
  tests/test_destination.py. Grepped for every lifecycle_on_ reference
  (including _dispatch.py/_introspect.py, which had none) to confirm no
  stragglers.
- BatchWriteError.partial(batch_size, written=N, cause=exc): the
  recommended constructor for the common contiguous-prefix partial-batch
  case. Every failed index is recorded with the real cause exception
  instead of a generic "not reached" placeholder, so the ack error detail
  that reaches Conduit reflects what actually went wrong. Reuses the
  existing exhaustive-accounting constructor path under the hood --
  still no code path that computes "ack everything not explicitly
  failed."
- Configure RPC handlers (Source and Destination) now catch
  pydantic.ValidationError explicitly and abort with INVALID_ARGUMENT
  plus a per-field detail message (errors.format_validation_error()),
  instead of relying on grpc.aio's generic "Unexpected <exception
  class>: ..." UNKNOWN-status wrapping of an uncaught exception -- per
  CLAUDE.md's "errors are API, actionable" standard.

Tests: tests/test_errors.py (BatchWriteError.partial construction +
cause-propagation), tests/test_configure_errors.py (real grpc.aio server +
real client, asserting INVALID_ARGUMENT status and that the field name/
message actually appear in the gRPC status detail -- not a mock of
pydantic or of the transport).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

* feat(cli): conduit-connector-sdk build + fix a real SIGTERM shutdown bug found by it

DX audit fix #5 (the #1 lever per the audit).

New `conduit-connector-sdk` console script, `build` subcommand
(_build.py/_cli.py): packages a connector project into one self-contained,
directly-executable artifact. Closes the design doc §1.1.6 packaging gap
-- Conduit execs a standalone connector subprocess with a clean
environment (no inherited PATH), so a `pip install`-then-shebang-script
connector cannot launch; this command produces a file whose shebang is an
absolute interpreter path resolved at build time.

Vendoring strategy: copies files from the *current environment's*
already-installed distributions (importlib.metadata: file manifests +
transitive Requires-Dist, marker-filtered for extras) rather than a fresh
pip install -- this SDK isn't published to PyPI yet, so a fresh resolve
would fail outright; this also means `build` needs no network access and
runs in ~0.2-0.4s. conduit-connector-sdk itself is vendored by copying its
actual installed location directly (works for editable dev installs too,
where importlib.metadata's file manifest is just a .pth redirect).

Not a plain zipapp: grpcio and pydantic's pydantic-core both ship compiled
extensions, which zipimport cannot load from inside a zip archive. Found
this the hard way -- an earlier version of this command raised BuildError
on any compiled extension, which would have made this SDK's own core
dependencies (grpcio, pydantic) impossible to vendor at all. Fixed by
generating a small, dependency-free bootstrap __main__.py (the only thing
zipimport ever runs directly) that extracts the real payload to a
per-build cache directory on first run -- the same fundamental approach
shiv/pex use -- then executes the connector's real entry point from those
extracted, real files. Verified end-to-end: built the example connector,
exec'd the artifact directly (not `python <artifact>`), confirmed
grpc/pydantic-core's .so files are present as real extracted files and
the handshake completes correctly.

Bug fix, found via that same end-to-end testing: sending a real SIGTERM
to a running serve() process whose connector's teardown() raised (e.g.
HTTPPollSource.teardown() accessing self._client before open() ever ran)
silently hung for the full watchdog deadline instead of shutting down
promptly. Root cause: _sigterm_shutdown()'s coroutine is scheduled via
run_coroutine_threadsafe from a signal handler with its Future never
awaited/checked, so an exception inside it was silently swallowed and
shutdown_requested was never set. Fixed: shutdown_requested.set() now runs
unconditionally in a finally block, with a clear stderr diagnostic
distinguishing "teardown raised" from "loop genuinely wedged" -- a buggy
teardown() no longer blocks the entire SIGTERM-triggered graceful path.
Also hardened the example connector's own teardown() to guard against
running before open() ever did.

Tests: tests/test_build.py builds the real example connector and execs
the resulting artifact directly as a subprocess (mirroring exactly how
Conduit's dispenser launches a plugin) -- asserts a valid handshake line,
an absolute-path shebang, compiled extensions present as real extracted
files, cache reuse on a second launch, and (the regression test for the
bug above) that a real SIGTERM triggers prompt graceful shutdown rather
than hanging until the watchdog fires.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

* fix(tests): make test_build.py's exec-based tests Windows-aware

CI's windows-latest jobs failed test_build.py: Windows has neither POSIX
executable bits (os.chmod's execute flags are a no-op there for arbitrary
extensions) nor shebang-based direct execution (CreateProcess dispatches
by file extension, not by parsing a leading `#!` line -- confirmed via the
actual CI failure: `OSError: [WinError 193] %1 is not a valid Win32
application` when invoking the bare .pyz path). This is exactly the
"Windows subprocess launch specifics ... untested until the CI matrix
actually runs it" risk the design doc already flagged as open, now
surfaced for real.

- The executable-bit assertion is skipped on Windows (nothing meaningful
  to assert there for a .pyz).
- Every subprocess invocation goes through a new `_exec_argv()` helper:
  the bare artifact path on POSIX (the actual "no `python` prefix" claim
  this test suite is about), `[sys.executable, artifact]` on Windows,
  documented as a real, known platform difference rather than silently
  worked around.
- `test_sigterm_triggers_prompt_graceful_shutdown` is skipped on Windows
  outright: `Popen.send_signal(SIGTERM)` maps to an unconditional
  `TerminateProcess()` there, not something `conduit.serve`'s SIGTERM
  handler ever observes, so the test would not exercise the graceful path
  it exists to pin. Windows-native graceful shutdown is out of scope for
  this fix (a real, separate feature involving different Windows IPC/
  signal-equivalent mechanisms).
- The compiled-extension-module check now also looks for `*.pyd` (Windows'
  extension suffix), not just `*.so`/`*.dylib`.

Verified: all 8 tests in tests/test_build.py still pass locally
(macOS/POSIX); the Windows-specific branches were validated against the
exact CI failure output, not guessed at blind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

* fix(serve): drain in-flight read/write before SIGTERM-triggered teardown

Tier-1 review gap (invariant 7): serve.py's _sigterm_shutdown ran
teardown() immediately on SIGTERM with no regard for an actively
streaming Run() call, letting teardown() (e.g. closing a DB pool) race
a live Source.read()/Destination.write(). Not a data-loss bug (a raced
write nacks the whole batch and Conduit redelivers), but a graceful-
shutdown gap on a Tier-1 SDK where graceful shutdown is the contract.

- _SourceServicer.drain()/_DestinationServicer.drain() factor out the
  same stop-then-wait ordering Stop() already performs for the
  deterministic path (Conduit Stop RPC -> Run ends -> Teardown RPC),
  reusable from serve.py's SIGTERM handler. Destination's Run() gained
  the same _stop_event/_stopped_event/finally shape Source already
  had, so drain() waits for the whole Run() generator to finish
  (including its already-computed ack response), not just for write()
  to return.
- _sigterm_shutdown awaits drain() before teardown(), bounded by the
  existing hung-loop watchdog deadline (unchanged) so a stuck
  connector still force-exits on schedule.
- Fixed the design doc overclaim (Risks & open questions §3): it
  asserted the ordinary SIGTERM-mid-write case was "covered by the
  ordinary SIGTERM-mid-write test," but the only existing SIGTERM test
  fires before Open(). Added the actual
  TestSigtermDrainsInFlightOperation::test_sigterm_mid_read_drains_before_teardown
  and ::test_sigterm_mid_write_drains_before_teardown integration tests
  (real grpc.aio server + client, deterministic via direct
  _on_sigterm invocation) and pointed the doc at them.

Gates: ruff format --check, ruff check, mypy --strict (18 source
files), full pytest (165 passed). Generated _grpc/ dirs unchanged.

Tier 1 (data path adjacent: SDK shutdown contract) -- does not merge
without DeVaris sign-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GQFzakPShAYj8CcwajYDD

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant