Skip to content

[Android] Do not release the custom-filter output memory owned by tensor_filter - #695

Open
myungjoo wants to merge 3 commits into
nnstreamer:mainfrom
myungjoo:fix/690-android-customfilter-output-validate
Open

myungjoo wants to merge 3 commits into
nnstreamer:mainfrom
myungjoo:fix/690-android-customfilter-output-validate

Conversation

@myungjoo

@myungjoo myungjoo commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

Addresses item H4 of #690.

Problem

nns_customfilter_invoke() hands the output handle of ml_pipeline_custom_invoke() to nns_parse_tensors_data(..., clone = TRUE, priv->out_info, &out). That handle is not owned by the JNI layer: ml_pipeline_custom_invoke() points out_data->tensors[i].data at the GstTensorMemory of tensor_filter (mapped GstMemory, released with _ml_tensors_data_destroy_internal (out_data, FALSE)), and the framework keeps using it after the callback returns.

nns_parse_tensors_data() did not distinguish that case from the one where it creates the handle itself, and treated every buffer as its own:

  • If CustomFilter.invoke() returns a TensorsData built from a tensors info other than the registered output info, the capacity differs from tensors[i].size, so the mapped memory is passed to g_free() — an invalid free of non-GLib memory — and a replacement is allocated. The framework never sees the replacement (the result is lost), and GStreamer later unmaps memory that was already released.
  • A Java list longer than the registered output info allocated and leaked a buffer on every invoke.
  • data->num_tensors was overwritten with the Java array length with no bound, so a list longer than ML_TENSOR_SIZE_LIMIT wrote past the fixed tensors array.
  • An empty list (a closed TensorsData) passed the parse with nothing copied, so an unfilled frame went downstream.

TensorsData.checkByteBuffer() validates a buffer against the info of that object, so an app can build a perfectly valid TensorsData from a different TensorsInfo and still trip all of this.

Fix

Split the two ownership cases in nns_parse_tensors_data():

  • Handle created here (every caller except the custom-filter: Pipeline.inputData, MLService.request, SingleShot.invoke) — allocate and clone exactly as before.
  • Handle given by the caller (the custom-filter output) — only fill it in. The tensor count and every buffer size must match the handle; a mismatch fails the parse, so nns_customfilter_invoke() returns -1, which tensor_filter turns into GST_FLOW_ERROR (the pipeline stops with an error), instead of the heap being corrupted. A caller-owned handle is accepted only with clone == TRUE, since the direct-access path would overwrite the caller's pointers; no caller does that today.

The tensor count is bounded by ML_TENSOR_SIZE_LIMIT in both cases. NNStreamer.TENSOR_SIZE_LIMIT is the same 256, so no valid Java-side object is rejected by that bound.

No change was needed in nnstreamer-native-customfilter.c: the validation belongs at the single point where the ownership is known.

Tests

APITestCustomFilter gains three negative cases that drive a CustomFilter returning output that does not match the registered output info — a larger buffer (testInvokeInvalidOutputSize_n), an extra tensor (testInvokeInvalidOutputCount_n), and a closed, empty object (testInvokeClosedOutput_n). Before this change the larger buffer passed the mapped memory to g_free(), the extra tensor leaked a buffer on every invoke, and the closed object delivered an unfilled frame to the sink; now the invoke fails for all three and nothing reaches the sink.

nnstreamer_capi_custom.invoke_output_buffer_p locks down what the binding relies on: the invoke callback of a custom-easy filter gets an output handle whose tensor count and buffer sizes come from the registered output info, with memory already allocated by tensor_filter, and what it writes there is what the sink receives.

Verification

The instrumented tests need a device, so the JNI change itself is not exercised by CI — the Android job only builds it. To verify the fix locally the patched control flow was extracted into a standalone harness and run under ASan/UBSan. With the pre-fix logic the caller-owned buffer is freed inside the parse and then used:

ERROR: AddressSanitizer: heap-use-after-free
  freed by thread T0 here:
    #1 parse (h4_logic_before.c:45)   <- g_clear_pointer (&data->tensors[i].data, g_free)

With this change all eight cases (matching output, size mismatch, count mismatch, empty object, new handle with and without a size change, no-clone, over-limit count) pass clean.

🤖 Generated with Claude Code

@myungjoo myungjoo added the DONOTMERGE Work in progress. Do not merge. label Sep 9, 2026
@myungjoo

myungjoo commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

이 리뷰는 별도 컨텍스트의 리뷰 에이전트가 작성한 것을 옮겨 적은 것입니다. (This review was written by a separate code-review agent in an isolated context and is being relayed here as-is.)

Scope of this review

Reviewed against #690 item H4 (Android JNI CustomFilter output size mismatch → g_free of GStreamer-mapped memory). All statements below are based on reading the actual diff and the current repository code (commit 94fae0b), not assumptions — points that could not be verified against this repository (because they depend on the external nnstreamer core library, not vendored here) are explicitly marked as speculation.

Does it fix H4? — Yes, verified

  • nns_customfilter_invoke() (nnstreamer-native-customfilter.c:125) passes &out, where out is the non-NULL handle owned by ml_pipeline_custom_invoke() (c/src/ml-api-inference-pipeline.c:2839-2847, wraps mapped GstMemory). In nns_parse_tensors_data(), this makes *data_h != NULL on entry, so created = FALSE.
  • With created == FALSE, the new code path never calls g_clear_pointer(..., g_free) / g_malloc on the caller's buffer; a tensor-count or per-tensor-size mismatch now goto done and return FALSE without touching *data_h, so nns_customfilter_invoke() correctly returns -1 instead of freeing GstMemory-owned data. This removes the invalid free / leak / later use-after-free described in H4.
  • The new num_tensors > ML_TENSOR_SIZE_LIMIT bound also closes the "write past the fixed tensors[ML_TENSOR_SIZE_LIMIT] array" part of H4 for all callers, and matches NNStreamer.TENSOR_SIZE_LIMIT (256 on both sides, confirmed) — unreachable from a legitimate Java TensorsData/TensorsInfo, so purely defensive, no behavior change for valid input.

Regression check across the 4 callers of nns_parse_tensors_data() — No regression found

Checked each call site directly:

  • nnstreamer-native-pipeline.c:651 (Pipeline.inputData): in_data = NULL initialized right before the call → created always TRUE → identical control flow to before the change.
  • nnstreamer-native-service.c:360 (MLService.request): in_data = NULL initialized before the call → created always TRUE → unaffected.
  • nnstreamer-native-singleshot.c:237,245 (SingleShot.invoke, both input and output): in_data = out_data = NULL initialized before both calls → created always TRUE for both → unaffected. (Also, clone == FALSE here, so the split if (created) {...} else {...} block inside if (clone) isn't even reached.)
  • Only nnstreamer-native-customfilter.c:125 passes a pre-existing (non-NULL) handle, matching the PR's own claim ("no change was needed in nnstreamer-native-customfilter.c... only such caller").
  • The refactor of the created == TRUE branch (dropping the data->tensors[i].data && guard before g_clear_pointer) is behavior-preserving: g_clear_pointer is a no-op on a NULL pointer per GLib semantics, so removing the explicit null check changes nothing.

Conclusion: the fix is correctly scoped to the one caller that actually owns a foreign buffer; the other three callers are provably unaffected by direct code inspection.

Size / scope of the change

Appropriately scoped: 3 files, and the actual production-code fix is small (nnstreamer-native-api.c, +32/-8 lines, all inside nns_parse_tensors_data()); the rest is test code. No unrelated modules are touched.

Test coverage — partially sufficient, with a gap worth flagging

  • tests/capi/unittest_capi_inference.cc (nnstreamer_capi_custom.invoke_output_buffer_p) does run in CI: .github/workflows/gbs_build.yml builds x86_64 with --define "unit_test 1", which executes tests/capi. This gives a real CI gate against a future regression that breaks the custom-filter output-ownership contract (fixed-size, framework-owned buffer). Good.
  • However, the three Android instrumented tests added in APITestCustomFilter.java (testInvokeInvalidOutputSize_n, testInvokeInvalidOutputCount_n, testInvokeClosedOutput_n) — which are the only tests that exercise the actual JNI code path fixed here — do not run in CI. .github/workflows/android.yml only builds the Android library (abi matrix, "Android Build Test"); there is no emulator/device step that runs androidTest. The PR description itself acknowledges this ("the Android job only builds it") and instead offers an out-of-repo ASan/UBSan run on an extracted copy of the logic as justification. That out-of-band verification is not reproducible by anyone else and leaves no CI trace.
    • Net effect: a future change to nns_parse_tensors_data() or its 3 non-customfilter callers (Pipeline.inputData, MLService.request, SingleShot.invoke) has no CI-executed test at all that would catch a regression in those call paths — this is a pre-existing structural gap in the repo (Android instrumented tests never run in CI), not something introduced by this PR, but it does mean this PR's own safety claim for the JNI layer relies entirely on manual/offline verification. Recommend (non-blocking) tracking a follow-up to run androidTest on an emulator in CI, or add a lightweight host-runnable JNI test harness.
  • Minor test-quality nit (LOW): in the 3 new APITestCustomFilter.java tests, assertFalse(mInvalidState) is dead/tautological. runCustomFilterPipeline() registers mCountCb, which only does mReceived++; only mSinkCb (unused by these tests) ever sets mInvalidState = true. Since mInvalidState is reset to false in @Before setUp() and never mutated by mCountCb, this assertion always trivially passes regardless of actual behavior. The tests' real verification is assertEquals(0, runCustomFilterPipeline(...)), which is sound — but the extra assertion should either be removed or the test should register mSinkCb/an equivalent callback that can actually observe an invalid state, to avoid giving false confidence.

Documentation

CustomFilter.Callback.invoke()'s Javadoc (CustomFilter.java) does not state that the returned TensorsData must exactly match the registered output TensorsInfo (same tensor count, same per-tensor byte size). This PR changes user-visible behavior for a mismatch from "silently corrupts the heap" to "pipeline invoke fails" — worth documenting this contract explicitly in the Callback.invoke() Javadoc so app developers understand why a mismatched return value now causes a hard failure. LOW-MEDIUM, non-blocking, but a reasonable ask given the behavior-contract change.

Speculative note (not verified against nnstreamer core, flagged explicitly as speculation)

ml_pipeline_custom_invoke() (c/src/ml-api-inference-pipeline.c:2839-2847, unmodified by this PR) only copies .data from the real GstTensorMemory* out into the out_data handle; it never syncs .size with out[i].size. out_data->tensors[i].size instead comes from _ml_tensors_data_create_no_alloc() → gst_tensors_info_get_size(). If a custom-easy filter's registered output TensorsInfo is created with NNStreamer.TensorFormat.FLEXIBLE (Java exposes this via new TensorsInfo(TensorFormat.FLEXIBLE), and ml_pipeline_custom_easy_filter_register() does not appear to reject a FLEXIBLE out info), gst_tensors_info_get_size() for a flexible tensor is, as far as I can tell from this repo's own comments about FLEXIBLE handling (c/src/ml-api-common.c:1091-1104), not a fixed real byte size — which would make the new strict data->tensors[i].size != data_size check in this PR's fix fail on essentially every non-empty output for a FLEXIBLE-format custom filter. I could not confirm the exact return value of gst_tensors_info_get_size() for flexible tensors since that function lives in the external nnstreamer core library, not in this repo — this is speculation, not a confirmed bug. Importantly, this would not be a new regression: under the pre-fix code, that same size mismatch (0 vs. actual) would already have triggered the exact H4 heap-corruption bug on every invoke, so a FLEXIBLE-output custom-easy filter was already broken/unsafe before this PR. After this fix it would fail cleanly instead of corrupting memory, which is the correct direction — but it's undocumented and untested by this PR. Recommend confirming this with a quick manual test (or filing it as a separate follow-up item if confirmed) rather than blocking on it here.

Summary

The core H4 fix is correct and precisely scoped; the "created vs. caller-owned" split is proven safe by direct inspection of all 4 call sites, and the fix is backed by a CI-executed C unit test. Remaining items are non-blocking: (1) Android instrumented tests (the most direct regression guard for the actual JNI bug) don't run in CI — a pre-existing repo-wide gap this PR doesn't need to solve but should be tracked; (2) a dead assertion in the new Java tests; (3) a documentation gap on the Callback.invoke() output contract; (4) an unverified, speculative concern about FLEXIBLE-format custom-filter output that pre-dates this PR.

@myungjoo

myungjoo commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

리뷰 감사합니다. 각 항목에 대한 대응입니다 (49e23a5).

LOW-MEDIUM — CustomFilter.Callback.invoke()의 계약이 문서화되지 않음

동의합니다. 이 PR이 새로 강제하는 요구사항이므로 PR 안에서 처리했습니다. Callback.invoke()의 Javadoc에 반환 데이터가 파이프라인의 출력 메모리로 복사되므로 create()에 넘긴 출력 정보와 일치해야 하고, 다르면 버퍼가 drop되고 에러가 보고된다는 점을 명시했습니다.

LOW — 신규 negative test의 assertFalse(mInvalidState)가 죽은 assertion

맞습니다. 세 테스트 모두 mCountCb를 쓰는데 그 콜백은 mInvalidState를 건드리지 않으므로 항상 통과합니다. 세 줄 모두 제거했습니다. 실제 검증은 assertEquals(0, ...)가 합니다.

LOW (추측) — FLEXIBLE 포맷 출력이 이 수정 이후 항상 실패할 가능성

코드를 확인한 결과 성립하지 않습니다.

  • TensorsInfo.getTensorSize() (TensorsInfo.java:230)는 포맷과 무관하게 mInfoList.get(index).getSize()를 반환합니다. 포맷은 "STATIC일 때 양수여야 한다"는 검증에만 쓰이고 값 자체를 바꾸지 않습니다. 따라서 TensorsData.allocate(flexibleInfo)가 잡는 용량은 STATIC일 때와 같은 값입니다.
  • 네이티브 쪽 data->tensors[i].size는 _ml_tensors_data_create_no_alloc() (ml-api-common.c:792)이 gst_tensors_info_get_size()로 채우며, 이것도 같은 dim/type 계산입니다.
  • 즉 양쪽이 같은 근거로 같은 값을 얻으므로, dimension/type이 선언된 FLEXIBLE out_info는 수정 전후 모두 일치합니다. dimension이 미지정이면 양쪽 다 0이라 역시 일치합니다.
  • 실제로 불일치가 나는 경우는 앱이 등록된 out_info와 다른 TensorsInfo로 만든 데이터를 반환하는 경우뿐이고, 그것이 정확히 H4가 지적한 heap corruption 케이스입니다. 수정 전에는 매 프레임 매핑된 GstMemory를 g_free하고 결과는 프레임워크에 전달되지도 않았으므로, 그 경로는 "동작하다가 실패하게 된" 것이 아니라 애초에 동작한 적이 없습니다. 조용한 메모리 오염이 명시적 에러로 바뀐 것입니다.

코드 변경은 하지 않았습니다.

MEDIUM — Android instrumented test가 CI에서 실행되지 않음

사실입니다. android.yml은 AAR 빌드만 하고 androidTest는 기기/에뮬레이터가 필요해 실행되지 않습니다. 이 PR 본문에도 같은 한계를 적어두었습니다.

다만 이 PR에서 해결할 수 있는 범위를 넘어섭니다 — 에뮬레이터 job 추가는 CI 인프라 변경이라 별도 논의가 필요하고, 이 저장소에 androidTest가 도입된 이래의 구조적 한계이지 이 PR이 만든 문제가 아닙니다. 그래서 대신 CI에서 실제로 도는 nnstreamer_capi_custom.invoke_output_buffer_p를 추가해, JNI 수정이 전제하는 계약(custom-easy invoke 콜백이 받는 출력 핸들의 텐서 개수와 버퍼 크기는 등록된 out_info에서 오고, 메모리는 tensor_filter가 이미 할당해 둔 것이며, 거기에 쓴 값이 sink에 도달한다)이 깨지면 CI가 잡도록 했습니다. 지적하신 "3개의 non-customfilter 호출부"는 이번 변경에서 동작이 동일하고(created가 항상 TRUE), 각각 APITestPipeline / APITestSingleShot / APITestMLService가 덮고 있습니다.

에뮬레이터 job 도입은 별도 이슈로 다루는 것이 맞다고 봅니다.


이 코멘트는 별도 컨텍스트의 리뷰 에이전트가 남긴 리뷰에 대한 대응입니다.

@myungjoo

myungjoo commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

이 리뷰는 별도 컨텍스트의 리뷰 에이전트가 작성한 것을 옮겨 적은 것입니다. (This review was written by a separate code-review agent in an isolated context and is being relayed here as-is.) 이것은 이전 리뷰(#695#issuecomment-5594506694)에 대한 재리뷰이며, 작성자 대응(49e23a5) 반영 후 전체 diff를 다시 직접 코드로 검증했습니다.

이전 리뷰 지적사항 해소 여부 — 모두 확인됨(Verified)

각 항목을 실제 코드로 직접 재확인했습니다.

  1. CustomFilter.Callback.invoke() Javadoc 문서화 (LOW-MEDIUM) — 해소됨. CustomFilter.java에 "returned data is copied into the output memory of the pipeline... mismatch drops the buffer and reports an error"가 추가되었고, 이는 nnstreamer-native-api.c의 nns_parse_tensors_data()가 실제로 강제하는 동작(개수/크기 불일치 시 파싱 실패 → nns_customfilter_invoke()가 -1 반환)과 일치합니다.
  2. assertFalse(mInvalidState) 죽은 assertion (LOW) — 해소됨. APITestCustomFilter.java에서 신규 3개 negative test에서만 해당 줄이 제거되었고, 기존 테스트(244, 315행)의 동일 assertion은 그대로 남아있어 회귀가 없습니다. mCountCb는 mReceived만 증가시키므로 애초에 mInvalidState를 검증할 수 없었던 부분이 정확히 제거되었습니다.
  3. FLEXIBLE 포맷 관련 추측성 지적 (LOW, speculation) — 작성자의 반박을 코드로 직접 검증했습니다. 반박은 타당합니다.
    • TensorsInfo.getTensorSize() (TensorsInfo.java:230-243)는 mInfoList.get(index).getSize()를 반환하며, mFormat은 STATIC일 때 "size > 0"인지 검증하는 데만 쓰이고 계산값 자체에는 영향을 주지 않습니다.
    • _ml_tensors_data_create_no_alloc() (ml-api-common.c:791-792)도 동일하게 gst_tensors_info_get_size(&_info->info, i)로 크기를 채우며, format 자체가 아니라 dimension/type만으로 계산됩니다.
    • 즉 등록된 out_info가 STATIC이든 FLEXIBLE이든 Java 쪽 캐패시티 계산과 네이티브 쪽 기대 크기 계산이 "같은 out_info의 같은 dimension/type"에서 나오므로, 정상적으로 동작하는 커스텀 필터(등록된 out_info로 TensorsData.allocate()한 결과를 반환하는 경우)라면 포맷과 무관하게 크기가 일치합니다. 이 부분은 코드로 확인되며 추측이 아닙니다.
    • 다만 gst_tensors_info_get_size() 자체는 외부 nnstreamer core 라이브러리 함수라 이 저장소에는 구현이 없고, "FLEXIBLE 포맷에서 실제 tensor_filter가 할당하는 GstMemory 크기"가 이 계산값과 항상 일치하는지는 이 저장소만으로는 최종 확인이 불가능합니다(원 리뷰와 작성자 모두 이미 인지하고 있는 한계이며, 이 PR이 만든 문제가 아니라 기존 프레임워크 계약에 속하는 부분입니다). 따라서 논파(rebuttal)의 핵심 논지(양쪽이 같은 공식으로 같은 값을 얻는다)는 코드로 검증되어 타당하다고 판단하며, 이 PR을 블로킹할 사유는 아닙니다.
  4. Android instrumented test가 CI에서 실행되지 않음 (MEDIUM) — 사실 확인됨, 미해결이지만 논의된 대로 스코프 밖으로 남겨두는 것이 합리적입니다. .github/workflows/android.yml을 직접 확인한 결과 abi 매트릭스로 AAR만 빌드하고 androidTest/에뮬레이터 스텝이 전혀 없습니다. 저장소의 다른 모든 workflow(daily-build-android.yml, nns_api_testhub.yml 등)에도 androidTest/emulator/instrumentation 관련 스텝이 없음을 grep으로 확인했습니다 — 저장소 전체에 걸친 기존 구조적 한계이며, 대신 추가된 tests/capi/unittest_capi_inference.cc의 nnstreamer_capi_custom.invoke_output_buffer_p가 .github/workflows/gbs_build.yml의 x86_64 --define "unit_test 1" 빌드에서 실제로 실행됨을 packaging/machine-learning-api.spec의 %check 섹션(bash %{test_script} ./tests/capi/unittest_capi_inference)으로 직접 확인했습니다. 이 C 테스트는 JNI 코드가 전제하는 계약(출력 핸들의 텐서 개수/버퍼 크기는 등록된 out_info에서 오고, 메모리는 tensor_filter가 이미 할당해 둔 것)을 CI에서 검증하므로, 이 계약이 깨지는 향후 회귀는 CI가 잡습니다. Non-blocking, 트래킹 이슈 권장.

추가로 직접 검증한 사항 (이번 재리뷰에서 새로 확인)

  • H4 수정 정확성: ml_tensors_data_s.tensors[ML_TENSOR_SIZE_LIMIT] (c/src/ml-api-internal.h:219)가 고정 크기 배열임을 확인했고, 수정 전 코드는 data->num_tensors에 bound 체크 없이 Java 배열 길이를 대입해 OOB 쓰기가 가능했습니다. 이번 PR의 num_tensors > ML_TENSOR_SIZE_LIMIT 체크는 4개 호출부 모두에 공통 적용되어 (customfilter뿐 아니라 Pipeline/MLService/SingleShot 경로도) 안전성이 개선되었습니다 — 부수적이지만 긍정적인 효과입니다.
  • 4개 호출부 회귀 없음: nnstreamer-native-pipeline.c:651, nnstreamer-native-service.c:360, nnstreamer-native-singleshot.c:237,245가 모두 호출 직전에 핸들을 NULL로 초기화함을 직접 확인했습니다 (created가 항상 TRUE). nnstreamer-native-customfilter.c:125만 nns_customfilter_invoke()의 non-NULL 파라미터 out을 그대로 넘기므로 created가 FALSE가 되는 유일한 경로입니다. 코드 검증 결과 이 PR의 claim과 일치합니다.
  • 부분 실패 시 동작: 여러 텐서 중 일부는 크기가 맞고 뒤쪽 텐서에서 불일치가 나는 경우, 앞쪽 텐서에 대해서는 이미 memcpy가 수행된 뒤 실패로 종료됩니다. 하지만 이 경우 nns_customfilter_invoke()가 -1을 반환해 프레임워크가 해당 출력 프레임 전체를 폐기하므로(테스트에서 sink 수신 0건으로 확인됨), 실질적인 안전 문제는 없습니다.
  • 새로 닫힌(close()된) TensorsData 반환 케이스: TensorsData.close()는 mDataList.clear()만 수행하므로 이후 native에서 GetArrayLength가 0을 반환 → num_tensors(0) != data->num_tensors(1)로 안전하게 실패 처리됨을 확인했습니다. NPE나 크래시 없이 정상적으로 방어됩니다.
  • 최종 커밋(49e23a5)의 리포맷 변경: git diff로 직접 대조한 결과, C 테스트 파일의 변경분은 순수 clang-format 개행 정리이며 로직 변화가 없습니다.
  • 스코프: git diff --stat 기준 4개 파일, +315/-8. 프로덕션 코드 변경은 nnstreamer-native-api.c 한 곳(+32/-8 상당)뿐이고 나머지는 테스트/문서입니다. 무관한 모듈을 건드리지 않았습니다.

결론

이전 리뷰에서 제기된 4개 항목 중 3개(Javadoc, 죽은 assertion, FLEXIBLE 반박)는 코드로 확인한 결과 정확하고 타당하게 처리되었습니다. 나머지 1개(Android instrumented test가 CI에서 실행되지 않음)는 사실이지만 이 PR의 스코프를 벗어나는 저장소 전반의 기존 구조적 한계이며, 이를 보완하기 위해 CI에서 실제로 실행되는 C 유닛테스트를 추가한 것으로 충분히 완화되었다고 판단합니다. 새로운 이슈는 발견되지 않았습니다.

리뷰 시점 기준 Tizen GBS build on Ubuntu (x86_64, --define "unit_test 1") 체크가 pending 상태였습니다 — 신규 C 테스트가 이 잡에서 실행되므로, merge 전에 반드시 이 체크의 통과 여부를 확인하시기 바랍니다(코드 이슈는 아니며 단순 CI 완료 대기 상태로 보입니다).

Blocking 이슈 없음(No blocking issues found).

@myungjoo myungjoo removed the DONOTMERGE Work in progress. Do not merge. label Sep 9, 2026
@myungjoo
myungjoo marked this pull request as ready for review September 9, 2026 02:28

@myungjoo-bot myungjoo-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review (transcribed from an AI review agent's report; please verify before acting).

Summary: The PR makes nns_parse_tensors_data() distinguish a handle it creates (allocate + clone, as before) from one the caller passes in (fill in place; tensor count and every size must match, else the parse fails), and bounds the Java tensor count by ML_TENSOR_SIZE_LIMIT (256, matching NNStreamer.TENSOR_SIZE_LIMIT) before any index. The defect is confirmed on main: nns_customfilter_invoke() (nnstreamer-native-customfilter.c:125) passes the non-NULL out from ml_pipeline_custom_invoke() (c/src/ml-api-inference-pipeline.c:2846-2848, tensors[i].data = out[i].data, released with free_data = FALSE), and main's clone branch did g_clear_pointer (&data->tensors[i].data, g_free) on a size mismatch, g_malloced unseen replacements for extra Java tensors, and wrote data->num_tensors unbounded. All four call sites were checked: pipeline.c:651, service.c:360, singleshot.c:237/245 initialise the handle to NULL right before the call, so their behaviour is byte-equivalent (data is always NULL from _ml_tensors_data_create_no_alloc); the custom-filter path is the only caller-owned one. In nnstreamer, a negative invoke return maps to GST_FLOW_ERROR plus a posted invoke-failure application message (tensor_filter.c:955-974), consistent with "nothing reaches the sink and the pipeline reports an error". No local-ref leaks in the new error paths; jsize -> guint is safe; non-direct buffers cannot reach GetDirectBufferAddress because TensorsData.checkByteBuffer rejects them on insert. merge-tree is clean, all 10 checks pass, and the x86_64 unit_test 1 log shows nnstreamer_capi_custom.invoke_output_buffer_p OK (368 ms), 241 tests passed. All three commits are DCO-signed. Approving; the items below are wording-level.

  1. [Low] Commit message 94fae0b (and PR text): "Each of them hit the invalid free before the previous commit" is inaccurate for two of the three Android cases. On base, the extra-tensor case leaks a g_malloced buffer per invoke (tensor[0] matched, tensor[1] was NULL so it was allocated, never freed); the closed/empty case set num_tensors = 0, skipped the loop, returned TRUE and delivered an unfilled frame to the sink. Only the larger-buffer case hits the invalid free. All three still fail before / pass after, so the tests are valid; suggest "each of them corrupted or silently mis-handled the framework's output buffer".
  2. [Low] CustomFilter.java:38-39 Javadoc: "the buffer is dropped and the pipeline reports an error" understates the effect. GST_FLOW_ERROR from tensor_filter puts the pipeline in the error state (the new tests themselves catch IllegalStateException on subsequent inputData / stop), so it is not a per-buffer drop like returning null. Suggest "the invoke fails and the pipeline stops with an error".
  3. [Low] nnstreamer-native-api.c:594-596 (PR side): the !clone branch still does data->tensors[i].data = data_ptr even when created == FALSE, which would silently replace a caller-owned pointer. No caller does (clone = FALSE, pre-existing handle) today, so it is a latent trap rather than a bug; a g_return_val_if_fail (created || clone, FALSE) or a note in the new @param doc would lock the contract in.
  4. [Low, informational] The three APITestCustomFilter _n tests need a device and do not run in CI (android.yml builds the AAR only); the C test covers the framework-side contract, not the JNI code itself. Pre-existing repo gap, acceptable here.

No back-door or suspicious behavior found.

@myungjoo myungjoo added the DONOTMERGE Work in progress. Do not merge. label Sep 10, 2026
myungjoo and others added 3 commits September 10, 2026 15:14
nns_parse_tensors_data() may be given a data handle that already holds
buffers. The only such caller is nns_customfilter_invoke(), where the
handle wraps the output memory of tensor_filter: it is mapped GstMemory,
not a GLib allocation, and the framework unmaps and keeps using it after
the callback returns.

The clone path treated those buffers as its own. When the TensorsData
returned by CustomFilter.invoke() was built from a tensors info other
than the registered output info, the sizes did not match, so the mapped
memory was passed to g_free() and a replacement was allocated. That is
an invalid free (heap corruption), the framework never sees the
replacement, and GStreamer later unmaps memory this code already
released. A Java list longer than the registered output info allocated
and leaked a buffer on every invoke, one longer than
ML_TENSOR_SIZE_LIMIT wrote past the fixed tensors array, and an empty
list (a closed TensorsData) passed the parse with nothing copied, so an
unfilled frame went downstream.

Split the two ownership cases. A handle created here still allocates and
clones as before. A handle given by the caller is only filled in: the
number of the tensors and every buffer size must match, and a mismatch
now fails the parse so the custom-filter invoke returns an error instead
of corrupting the heap. The tensor count is bounded in both cases.

A caller-owned handle is accepted only together with clone, because the
direct-access path would overwrite the caller's pointers. No caller does
that today; the check keeps it that way.

Fixes the item H4 of nnstreamer#690.

Signed-off-by: MyungJoo Ham <myungjoo.ham@samsung.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Android negative cases drive a CustomFilter whose invoke() returns a
TensorsData that does not match the registered output info. Before the
previous commit each of them mishandled the output memory of the
framework: the larger buffer passed the mapped memory to g_free(), the
extra tensor allocated and leaked a buffer on every invoke, and the
closed (empty) object passed the parse with nothing copied, so an
unfilled frame reached the sink. Now the invoke fails for all three and
nothing reaches the sink.

The C unittest locks down what the JNI binding relies on: the invoke
callback of a custom-easy filter is handed an output handle whose tensor
count and buffer sizes come from the registered output info, with the
memory already allocated by tensor_filter, and the data written into
that memory is what the sink receives. It runs in CI, so a change that
hands the callback a differently owned output buffer fails there rather
than only on a device.

Signed-off-by: MyungJoo Ham <myungjoo.ham@samsung.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Say in CustomFilter.Callback.invoke() that the returned data is copied
into the output memory of the pipeline, so it has to match the output
information passed to create(), and that a mismatch fails the invoke and
stops the pipeline with an error. That is not the per-buffer drop of
returning null: tensor_filter turns a failed invoke into GST_FLOW_ERROR.
The requirement is now enforced by the JNI layer, so it belongs in the
API documentation.

Also drop the assertions on mInvalidState from the new negative cases:
they run with a sink callback that only counts, so the flag can never be
set and the check cannot fail. The number of the received data is the
assertion that matters.

The remaining hunk reflows the new unittest to what clang-format wants.

Signed-off-by: MyungJoo Ham <myungjoo.ham@samsung.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@myungjoo
myungjoo force-pushed the fix/690-android-customfilter-output-validate branch from 49e23a5 to 7d792b7 Compare September 10, 2026 06:14
@myungjoo

Copy link
Copy Markdown
Member Author

Response to the approving review (#pullrequestreview-5151019871). All four items were checked against the code; three are addressed in this PR. The series was rewritten (d4c0f30/94fae0b/49e23a5 → 394979f/baadfb5/7d792b7) because item 1 is a commit-message error, and this repository keeps commit messages verbatim on main. Compared with the old head, the tree differs only by the three edits below.

1. [Low] "Each of them hit the invalid free" is wrong for two of the three cases — agreed, fixed.
Traced on the base: the extra-tensor case matches tensor[0], then finds tensor[1] zero-initialised by _ml_tensors_data_create_no_alloc (data == NULL), so it g_mallocs and leaks a buffer on each invoke. The closed case sets num_tensors = 0, skips the loop, returns TRUE, and an unfilled frame reaches the sink. Only the larger-buffer case hits the invalid free. The baadfb5 commit message and the PR body now describe each case separately. The empty-list case is also added to the problem statement in 394979f.

2. [Low] The Javadoc understates the effect — agreed, fixed.
Confirmed in nnstreamer tensor_filter.c, _gst_tensor_filter_transform_check_invoke_result(): invoke_res < 0 → GST_FLOW_ERROR; only invoke_res > 0 → GST_BASE_TRANSFORM_FLOW_DROPPED. Callback.invoke() now reads "the invoke fails and the pipeline stops with an error". The 7d792b7 message spells out the difference from returning null.

3. [Low] The !clone branch would overwrite a caller-owned pointer — agreed, fixed with the suggested guard.
Added g_return_val_if_fail (*data_h == NULL || clone, FALSE); next to the other argument checks, and the @param data_h doc now says clone must be TRUE for a caller-owned handle. Behaviour of existing callers is unchanged: pipeline.c:651, service.c:360 and singleshot.c:237/245 all pass a NULL handle, and customfilter.c:125 passes clone = TRUE.

4. [Low, informational] androidTest is not in CI — acknowledged, no change.
This is the same pre-existing gap noted in the PR body and in the earlier review thread. The CI-run C test covers the framework-side contract that the JNI code relies on.


Relayed on behalf of the author; the review above was written by an AI review agent.

@myungjoo

Copy link
Copy Markdown
Member Author

이 코멘트는 별도 컨텍스트의 리뷰 에이전트가 작성한 리뷰를 옮겨 적은 것입니다. 이것은 재리뷰이며, myungjoo-bot의 APPROVED 리뷰(4개 Low)와 그에 대한 작성자 대응(49e23a5, 이후 394979f/baadfb5/7d792b7로 이력 재작성)을 모두 읽고, 실제 코드를 직접 읽어 검증했습니다. 추측인 부분은 명시적으로 "추측"이라고 표기했습니다.

이력 재작성(rebase) 검증 — 트리 차이 확인됨

git diff 49e23a5 7d792b7를 직접 실행해 확인한 결과, 작성자의 주장("트리는 아래 3가지 편집만 다르다")이 정확합니다. 변경된 파일은 정확히 2개뿐입니다.

  • CustomFilter.java: Javadoc 문구를 "the buffer is dropped and the pipeline reports an error" → "the invoke fails and the pipeline stops with an error"로 수정.
  • nnstreamer-native-api.c: @param data_h 주석에 "(@A clone must be TRUE)" 추가 + g_return_val_if_fail (*data_h == NULL || clone, FALSE); 한 줄 추가.
    커밋 메시지 재서술(케이스별 설명)은 트리에 나타나지 않는 것이 정상이며(메시지만 변경), git log로 394979f/baadfb5/7d792b7의 메시지를 직접 읽어 아래에서 별도 검증했습니다.

이전 지적 1~3 해소 여부 — 모두 코드로 직접 검증, 해소 확인

(1) 커밋 메시지의 케이스별 서술이 base 동작과 일치하는가 — 일치함.
baadfb5와 394979f의 커밋 메시지를 base(8952123) 코드 흐름과 대조했습니다.

  • Larger-buffer 케이스: data->tensors[i].size != data_size → g_clear_pointer(&data->tensors[i].data, g_free) — 이것이 GstMemory 매핑 포인터에 대한 잘못된 free입니다. 메시지의 "the larger buffer passed the mapped memory to g_free()"와 일치.
  • Extra-tensor 케이스: base 코드에서 _ml_tensors_data_create_no_alloc()이 만든 핸들의 초과 인덱스는 data->tensors[i].data == NULL이므로 g_malloc만 발생하고 해제되지 않습니다 (leak). 메시지의 "the extra tensor allocated and leaked a buffer on every invoke"와 일치.
  • Closed(empty) 케이스: num_tensors = 0이 되어 루프를 건너뛰고 TRUE를 반환 → 채워지지 않은 프레임이 그대로 sink까지 전달. 메시지의 "the closed (empty) object passed the parse with nothing copied, so an unfilled frame reached the sink"와 일치.
    세 케이스 모두 "invalid free"였다는 이전 표현의 오류가 정확히 교정되었습니다.

(2) CustomFilter.java Javadoc이 tensor_filter의 invoke 실패 처리와 일치하는가 — 일치함, nnstreamer core 코드로 직접 확인.
WSL의 ~/nns/gst/nnstreamer/tensor_filter/tensor_filter.c의 _gst_tensor_filter_transform_check_invoke_result()를 직접 읽었습니다 (933~964행 부근): invoke_res < 0 → GST_FLOW_ERROR 반환, invoke_res > 0 → GST_BASE_TRANSFORM_FLOW_DROPPED(버퍼만 drop). nns_customfilter_invoke()는 nns_parse_tensors_data() 실패 시 -1을 반환하므로, 새 Javadoc 문구("the invoke fails and the pipeline stops with an error")가 실제 동작과 정확히 일치합니다. 이전 문구("the buffer is dropped")는 invoke_res > 0(즉 return 1, null 반환) 케이스와 혼동될 수 있었는데 이번 수정으로 명확히 구분되었습니다. 추가로 APITestCustomFilter.java의 runCustomFilterPipeline() 헬퍼가 이후 pipe.inputData()/pipe.stop()에서 IllegalStateException을 잡도록 작성되어 있어, "파이프라인이 에러 상태로 전이한다"는 문서 서술과 테스트 설계가 일치함을 확인했습니다.

(3) 새 g_return_val_if_fail (*data_h == NULL || clone, FALSE);가 기존 호출자 동작을 바꾸지 않는가 — 바꾸지 않음, 4개 호출부 모두 직접 확인.

  • nnstreamer-native-pipeline.c:651 — 호출 직전 ml_tensors_data_h in_data = NULL; (639행) 확인. *data_h == NULL이 참이므로 clone 값(TRUE)과 무관하게 guard 통과, 기존과 동일.
  • nnstreamer-native-service.c:360 — 호출 직전 ml_tensors_data_h in_data = NULL; (351행) 확인. clone = FALSE이지만 *data_h == NULL이라 guard 통과.
  • nnstreamer-native-singleshot.c:237, 245 — in_data = out_data = NULL; (231행)로 두 핸들 모두 NULL 초기화 확인. 두 호출 모두 clone = FALSE이지만 *data_h == NULL이라 guard 통과.
  • nnstreamer-native-customfilter.c:125 — nns_parse_tensors_data(..., TRUE, priv->out_info, &out)에서 out은 nns_customfilter_invoke()의 파라미터(비-NULL, ml_pipeline_custom_invoke()가 채운 핸들)이고 clone = TRUE. *data_h != NULL이지만 clone == TRUE라 guard 통과.
    4개 호출부 모두 guard를 통과하며 기존 동작이 변하지 않음을 확인했습니다. 동시에 이 guard는 "*data_h != NULL && clone == FALSE"라는, 현재는 아무도 쓰지 않지만 코드상 도달 가능했던 조합(caller-owned 포인터를 복사 없이 덮어써 버리는 latent trap)을 컴파일 타임이 아닌 런타임 assertion으로 막아, 향후 이 함수를 잘못된 인자로 호출하는 회귀를 방지합니다.

H4(#690) 자체가 해결되었는가 — 해결됨

nns_customfilter_invoke()가 넘기는 out은 항상 비-NULL(created = FALSE)이므로, 크기/개수 불일치 시 더 이상 GstMemory 매핑 포인터에 g_free를 호출하지 않고 goto done으로 파싱 실패 처리됩니다. num_tensors > ML_TENSOR_SIZE_LIMIT 체크도 4개 호출부 공통으로 적용되어 ml_tensors_data_s.tensors[ML_TENSOR_SIZE_LIMIT] 고정 배열에 대한 OOB 쓰기 가능성도 함께 닫혔습니다. 이슈 #690 본문의 H4 항목이 제시한 수정 방향("never free/reallocate a pre-existing buffer when created == FALSE; copy ... or fail")과 일치합니다(PR은 부분 복사 대신 실패 처리를 택했으며, 이는 이슈에서도 허용된 대안입니다).

회귀 / 스코프 / 테스트 — 새로운 이슈 없음

  • git diff 8952123 7d792b7 --stat 기준 4개 파일 변경(+317/-8): 프로덕션 코드는 nnstreamer-native-api.c(+42/-8 상당) 한 곳, 나머지는 Java Javadoc, androidTest, C 유닛테스트. 무관한 모듈을 건드리지 않았습니다.
  • CI로 실제 실행되는 tests/capi/unittest_capi_inference.cc의 nnstreamer_capi_custom.invoke_output_buffer_p를 직접 읽었습니다: custom-easy filter의 invoke 콜백이 받는 출력 핸들이 (a) 등록된 out_info로부터 개수/크기가 정해지고 (b) 프레임워크가 이미 할당한 메모리이며 (c) 콜백이 그 메모리에 쓴 값이 그대로 sink에 도달함을 검증합니다. .github/workflows/gbs_build.yml의 x86_64 --define "unit_test 1" 잡과 packaging/machine-learning-api.spec의 %check 섹션(bash %{test_script} ./tests/capi/unittest_capi_inference)에서 실제로 실행됨을 확인했습니다.
  • 다만 이 C 테스트는 순수 C API 경로만 검증하며, JNI의 nns_parse_tensors_data() 자체(이번 PR의 실제 수정 지점)는 여전히 androidTest(APITestCustomFilter 3개 신규 케이스)로만 검증되고, android.yml은 AAR 빌드만 하고 에뮬레이터/androidTest 스텝이 없어 CI에서 실행되지 않습니다. 이는 기존 리뷰 스레드에서 이미 지적·인지·논의된 저장소 전체의 구조적 한계이며 이번 PR이 새로 만든 문제가 아닙니다. 새로운 지적으로 추가하지 않고, 기존 논의대로 non-blocking으로 남깁니다.
  • 현재 시점 CI 체크는 이력 재작성 직후라 전부 pending 상태입니다(gh pr checks 695). merge 전 Tizen GBS build on Ubuntu (x86_64, --define "unit_test 1")을 포함한 필수 체크의 통과 여부를 확인하시기 바랍니다. 코드 이슈는 아닙니다.
  • inline review comment(gh api repos/nnstreamer/api/pulls/695/comments)는 없으며, 지금까지의 모든 지적은 top-level 리뷰/코멘트 스레드에서만 오갔습니다.

결론

새로 발견된 이슈는 없습니다(No new issues found). myungjoo-bot 리뷰의 4개 Low 지적 중 3개(커밋 메시지 정정, Javadoc 정확도, g_return_val_if_fail 가드)는 코드/커밋 메시지/nnstreamer core 소스를 직접 대조해 해소를 확인했고, 나머지 1개(androidTest가 CI에서 실행되지 않음)는 기존에 합의된 대로 스코프 밖의 사전 존재 한계로 남아 있습니다. Blocking 이슈 없음 — CI(특히 x86_64 unit_test)가 통과하는 조건 하에 approve 가능하다고 판단합니다.

@myungjoo myungjoo removed the DONOTMERGE Work in progress. Do not merge. label Sep 10, 2026

@myungjoo-bot myungjoo-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated re-review of head 7d792b7 (delta since the previously approved 49e23a5; verified directly by the review process, no separate agent this round).

Summary: The tree delta is 2 files, +6/-4: the CustomFilter.Callback.invoke() Javadoc now says a mismatch "fails the invoke and the pipeline stops with an error", the @param data_h doc states that clone must be TRUE for a caller-owned handle, and nns_parse_tensors_data() gains g_return_val_if_fail (*data_h == NULL || clone, FALSE). The three commits were rewritten so the messages are accurate. All three Low items from the previous review are addressed; the fourth (androidTest not in CI) was correctly left as is.

Verified on the head: the new guard is consistent with every caller — nnstreamer-native-customfilter.c:125 passes a non-NULL handle with clone = TRUE; pipeline.c:651 (clone = TRUE), service.c:360 and singleshot.c:237/245 (clone = FALSE) all pass a handle initialised to NULL immediately before the call, so their behaviour is unchanged. The Javadoc wording matches nnstreamer's gst/nnstreamer/tensor_filter/tensor_filter.c on main: invoke_res < 0 posts an invoke-failure application message and returns GST_FLOW_ERROR, while only invoke_res > 0 returns GST_BASE_TRANSFORM_FLOW_DROPPED. The rewritten baadfb5 message now describes the three Android negative cases separately (invalid free / leaked buffer per invoke / unfilled frame reaching the sink), and 394979f adds the empty-list case to the problem statement and documents the clone requirement. All three commits carry Signed-off-by.

CI on 7d792b7: all 10 checks pass, including the four Android ABIs that compile the new guard. The GBS x86_64 unit_test 1 job (102765837671) shows nnstreamer_capi_custom.invoke_output_buffer_p OK (369 ms), 241 tests passed in unittest_capi_inference, every other suite green. merge-base is current main. Approval stands; nothing further requested.

No back-door or suspicious behavior found.

@myungjoo myungjoo added PR/Ready2Go Bugfix This PR fixes a known bug. labels Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bugfix This PR fixes a known bug. PR/Ready2Go

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants