Skip to content

Lesson 1.5.4 — Exception & Stack Unwinding ​

Bài 1.5.4 — Exception & Stack Unwinding
Exceptions, Stack Unwinding, Callers & Error Boundaries • 35 phút
0:00 / 0:00

0. Metadata (Thông tin bài học) ​

Thuộc tínhGiá trị
Stage1 — JavaScript Execution Model
Module1.5 — Call Stack & Execution Tracing
Lesson1.5.4 — Exception & Stack Unwinding
CompetencyC02 — JavaScript Runtime (C02.6 Call Stack)
Depth TargetL3–L4 (Use / Debug)
PrerequisitesError Handling cơ bản (Stage 0), Stack Frames (Lesson 1.5.1), Nested Calls (Lesson 1.5.2), Recursion & stack growth (Lesson 1.5.3)
Cognitive LoadMedium–High

1. Why This Exists (Vì sao cần học) ​

Bạn đã biết một nested call bình thường quay về caller bằng return hoặc bằng cách đi đến cuối function. Nhưng exception tạo ra một control flow khác.

Xem đoạn code sau:

js
function a() {
  console.log("a:start");
  b();
  console.log("a:end");
}

function b() {
  console.log("b:start");
  throw new Error("boom");
}

a();

b() không return về a() theo đường bình thường. Sau throw, dòng tiếp theo trong b() không chạy. Nếu a() không có catch, dòng console.log("a:end") cũng không chạy.

Câu hỏi quan trọng không còn chỉ là "function nào return trước?" mà là: exception đi qua Call Stack như thế nào, frame nào bị bỏ khỏi stack, và ở đâu normal execution được phép tiếp tục?

Learning Goal (Mục tiêu học)

Bài này dùng Call Stack mental model để giải thích exception propagation và stack unwinding. Mục tiêu không phải học toàn bộ error-handling architecture, mà là trace được một synchronous exception từ nơi throw xảy ra đến catch boundary hoặc đến trạng thái unhandled.

2. Prerequisites (Yêu cầu đầu vào) ​

Trước khi học bài này, bạn cần:

  • [ ] Biết throw tạo ra abrupt completion thay vì normal completion.
  • [ ] Biết cú pháp cơ bản của try...catch.
  • [ ] Vẽ được Call Stack theo mô hình push → execute → pop.
  • [ ] Trace được caller → callee → resume trong nested calls.
  • [ ] Phân biệt được return value với control flow.
  • [ ] Biết mỗi invocation có frame/execution state riêng.

Nếu bạn vẫn chưa trace chắc được a() → b() → c() → return → resume, quay lại Lesson 1.5.2 — Nested Calls trước.

3. Learning Objectives (Mục tiêu học tập) ​

Sau bài này, bạn có thể:

  1. Trace một synchronous exception qua chuỗi nested calls 3–4 tầng.
  2. Giải thích stack unwinding bằng frame lifecycle thay vì chỉ nói "error đi lên trên".
  3. Xác định statement nào bị bỏ qua sau khi throw xảy ra.
  4. Phân biệt normal return flow với exception propagation.
  5. Xác định catch boundary gần nhất trong dynamic call chain.
  6. Dự đoán Call Stack còn lại khi exception được bắt ở một caller phía trên.
  7. Debug một case exception bị swallow hoặc bị catch ở sai boundary.
  8. Giải thích vì sao stack trace thường hữu ích để reconstruct caller chain tại thời điểm lỗi.

4. Mental Model (Mô hình tư duy) ​

Với normal call flow, caller tạm dừng ở call site, callee chạy, rồi control quay lại caller:

text
caller
  ↓ call
callee
  ↓ normal completion
caller resumes after call site

Với exception, nếu invocation hiện tại không xử lý exception bên trong chính nó, normal execution của invocation đó bị hủy và propagation tiếp tục ra caller:

text
throw
  ↓
current execution path stops
  ↓
no catch in current invocation
  ↓
current invocation exits abruptly
  ↓
frame removed
  ↓
propagate to caller
  ↓
repeat until catch boundary or unhandled

Một catch boundary thay đổi hướng control flow:

text
throw in c()
    ↓
c() has no catch
    ↓
c() exits abruptly
    ↓
b() has no catch
    ↓
b() exits abruptly
    ↓
a() has active catch around the call path
    ↓
control enters catch in a()
    ↓
a() may continue after try...catch

Mental Model (Mô hình tư duy)

Stack unwinding trong lesson này là quá trình loại bỏ các active invocation không xử lý được exception khi exception propagate ra ngoài. Không phải mọi throw đều lập tức pop mọi frame: nếu exception được catch ngay trong cùng invocation, frame đó vẫn đang active và control chuyển sang catch trong chính invocation đó.

Ba câu hỏi phải luôn hỏi khi trace exception ​

Tại mỗi tầng của Call Stack, hỏi:

  1. Exception đang ở invocation nào?
  2. Invocation này có active catch boundary bao quanh execution path hiện tại không?
  3. Nếu không có, invocation nào sẽ bị kết thúc và caller nào nhận propagation tiếp theo?

Nếu trả lời được ba câu này, stack unwinding trở thành một trace cơ học thay vì "error nhảy lung tung".

5. Core Concepts (Các khái niệm cốt lõi) ​

Essential (Bắt buộc) ​

Khái niệmÝ nghĩa trong lesson
ExceptionMột abrupt completion do throw tạo ra trong execution path hiện tại.
Throw siteVị trí nơi throw được thực thi.
Exception propagationQuá trình exception tiếp tục ra caller khi invocation hiện tại không xử lý nó.
Stack unwindingQuá trình các invocation không xử lý exception kết thúc đột ngột và frame của chúng rời Call Stack.
Catch boundarycatch đang active và bao quanh execution path nơi exception propagate tới.
Handled exceptionException đã được một catch nhận, cho phép control tiếp tục theo flow của catch/sau try...catch.
Unhandled exceptionException đi hết synchronous call chain mà không gặp catch phù hợp trong execution path.

Supporting (Hỗ trợ) ​

  • throw dừng normal execution của path hiện tại ngay tại throw site.
  • Statement sau throw trong cùng path không chạy trừ khi control flow đi vào một handler khác rồi quay tới một vùng code khác.
  • Caller chỉ resume bình thường sau call site nếu callee hoàn tất bình thường.
  • Nếu callee hoàn tất bằng exception và caller không catch, caller cũng không resume theo đường normal sau call site.
  • Frame chứa catch boundary có thể vẫn còn active; các frame bên trên nó mới là phần bị unwound.
  • Sau khi catch xử lý xong, function chứa catch có thể tiếp tục chạy các statement phía sau try...catch nếu không có abrupt completion mới.

Awareness (Biết tồn tại) ​

  • catch có thể throw lại exception, khiến propagation tiếp tục ra caller phía trên.
  • finally tham gia vào control flow khi rời try/catch, nhưng precedence chi tiết giữa return, throw và finally không phải mục tiêu của bài này.
  • Formatting của stack trace có thể khác giữa browser/runtime, nhưng caller chain vẫn là một signal quan trọng khi debug.
  • Exception trong callback chạy ở một thời điểm async khác không còn nằm trên synchronous call chain ban đầu; nội dung này thuộc Stage 3.

Out of Scope (Không học trong bài này) ​

  • Promise rejection, async/await, task, microtask, Event Loop — Stage 3.
  • Error taxonomy production-scale, retry policy, fallback strategy — các Stage sau.
  • React Error Boundary — Stage 8.
  • finally precedence nâng cao.
  • Engine-specific exception tables, native stack implementation, JIT deoptimization.
  • Source maps và distributed tracing.

6. Worked Example (Ví dụ phân tích từng bước) ​

Ta dùng đúng pattern canonical của Stage 1, nhưng thêm catch ở caller ngoài cùng để quan sát boundary:

js
function a() {
  console.log("a:start");

  try {
    b();
    console.log("a:after-b");
  } catch (error) {
    console.log("a:caught", error.message);
  }

  console.log("a:end");
}

function b() {
  console.log("b:start");
  c();
  console.log("b:end");
}

function c() {
  console.log("c:start");
  throw new Error("boom");
}

a();

Step 1 — a() được gọi ​

a() in a:start, sau đó vào try và gọi b().

Call Stack:

text
Global
└── a()
    └── b()

Frame a() vẫn active vì b() chưa hoàn tất.

Step 2 — b() gọi c() ​

b() in b:start, rồi gọi c().

Call Stack:

text
Global
└── a()
    └── b()
        └── c()

b() đang chờ c() hoàn tất. Dòng console.log("b:end") chưa được thực thi.

Step 3 — c() thực thi throw ​

c() in c:start, sau đó chạy throw new Error("boom");.

c() không có catch bao quanh throw site. Vì vậy c() không normal-return về b(). Invocation c() kết thúc đột ngột và frame c() rời stack.

Stack chuyển từ:

text
Global
└── a()
    └── b()
        └── c()

thành:

text
Global
└── a()
    └── b()

Exception tiếp tục propagate tới b().

Step 4 — b() cũng không có catch ​

b() không có handler cho path đang chạy. Vì vậy b() cũng kết thúc đột ngột.

Điều này có nghĩa console.log("b:end") không chạy.

Frame b() rời stack:

text
Global
└── a()

Exception tiếp tục propagate tới invocation a().

Step 5 — a() có active catch boundary ​

Lời gọi b() nằm bên trong try của a(). Khi exception propagate trở lại a(), control không tiếp tục tại statement ngay sau b();.

Vì vậy console.log("a:after-b") không chạy.

Thay vào đó, control chuyển vào catch, nên a() in a:caught boom.

Điểm quan trọng: frame a() không bị pop trước khi catch chạy. Chính invocation a() chứa catch boundary đang xử lý exception.

Step 6 — Sau catch, normal flow của a() tiếp tục ​

Sau khi catch hoàn tất và không throw thêm exception, a() tiếp tục tới console.log("a:end"), rồi kết thúc bình thường.

Output hoàn chỉnh là:

text
a:start
b:start
c:start
a:caught boom
a:end

Không có b:end và không có a:after-b.

Step 7 — Tóm tắt stack transition ​

text
PUSH:
Global
Global → a()
Global → a() → b()
Global → a() → b() → c()

THROW in c():
Global → a() → b() → c()
                    ↑ throw

UNWIND:
c() exits abruptly
Global → a() → b()

b() has no catch
b() exits abruptly
Global → a()

HANDLE:
a() catch receives exception
Global → a()

NORMAL COMPLETION:
a() finishes
Global

Quan sát quan trọng

Stack unwinding không có nghĩa chương trình luôn dừng hoàn toàn. Nó chỉ loại bỏ các invocation mà exception đi xuyên qua. Nếu gặp catch boundary, execution có thể tiếp tục từ handler đó.

7. Prediction Exercise (Bài tập dự đoán) ​

Bài 1 — Dòng nào chạy? ​

Đừng chạy code. Hãy ghi chính xác output và đánh dấu function nào bị unwound.

js
function first() {
  console.log("first:start");
  second();
  console.log("first:end");
}

function second() {
  console.log("second:start");
  third();
  console.log("second:end");
}

function third() {
  console.log("third:start");
  throw new Error("stop");
}

try {
  first();
} catch (error) {
  console.log("caught");
}
Đáp án & Giải thích

Output:

text
first:start
second:start
third:start
caught

third() throw và không catch nên third() kết thúc đột ngột. Exception propagate sang second(), khiến second() bị unwound trước khi second:end chạy. Sau đó first() cũng bị unwound trước first:end. catch ở Global-level try...catch nhận exception và in caught.

Bài 2 — Catch ở giữa stack ​

Đừng chạy code. Hãy dự đoán output và Call Stack ngay khi catch của middle() bắt đầu chạy.

js
function outer() {
  console.log("outer:start");
  middle();
  console.log("outer:end");
}

function middle() {
  console.log("middle:start");

  try {
    inner();
  } catch (error) {
    console.log("middle:caught");
  }

  console.log("middle:end");
}

function inner() {
  throw new Error("boom");
}

outer();
Đáp án & Giải thích

Output là:

text
outer:start
middle:start
middle:caught
middle:end
outer:end

Khi middle() bắt đầu chạy catch, frame inner() đã bị unwound nhưng frame middle() vẫn active vì chính middle() chứa handler. Call Stack lúc đó:

text
Global
└── outer()
    └── middle()   ← đang chạy catch

Sau catch, middle() tiếp tục normal flow với middle:end, return về outer(), rồi outer() resume và in outer:end.

Bài 3 — Throw ngay trong cùng invocation có catch ​

js
function run() {
  try {
    console.log("before");
    throw new Error("boom");
    console.log("unreachable");
  } catch (error) {
    console.log("caught");
  }

  console.log("after");
}

run();

Câu hỏi: Có bao nhiêu function frame bị unwound trước khi catch chạy?

Đáp án & Giải thích

Không có function frame nào cần bị pop trước khi handler chạy. Exception được throw và catch ngay trong cùng invocation run().

Output là:

text
before
caught
after

console.log("unreachable") không chạy vì normal path trong try đã bị cắt tại throw.

8. Implementation Lab (Bài lab thực hành) ​

Lab 1 — Guided: thêm error boundary đúng tầng ​

Cho code:

js
function parsePort(raw) {
  const port = Number(raw);

  if (!Number.isInteger(port) || port <= 0) {
    throw new Error("Invalid port");
  }

  return port;
}

function loadConfig(rawPort) {
  return {
    port: parsePort(rawPort),
  };
}

Yêu cầu: viết startApp(rawPort) sao cho:

  • gọi loadConfig(rawPort);
  • nếu thành công, in starting <port>;
  • nếu parsing throw, startApp catch và in config error;
  • exception không propagate ra ngoài startApp trong lab này.
Đáp án tham khảo
js
function startApp(rawPort) {
  try {
    const config = loadConfig(rawPort);
    console.log("starting", config.port);
  } catch (error) {
    console.log("config error");
  }
}

Với startApp("abc"), parsePort() throw. parsePort() bị unwound, sau đó loadConfig() cũng bị unwound. startApp() chứa catch boundary nên frame startApp() vẫn active và control chuyển sang catch.

Lab 2 — Partial Scaffold: trace trước khi sửa ​

js
function readUser(user) {
  return readProfile(user.profile);
}

function readProfile(profile) {
  return readName(profile.name);
}

function readName(name) {
  if (!name) {
    throw new Error("Missing name");
  }

  return name;
}

function renderUser(user) {
  // TODO: add boundary here
  const name = readUser(user);
  console.log("user:", name);
}

Yêu cầu:

  1. Với renderUser({ profile: {} }), vẽ stack tại throw site.
  2. Ghi thứ tự frame bị unwound nếu chưa sửa code.
  3. Thêm boundary vào renderUser để in Cannot render user thay vì để exception đi ra ngoài.
  4. Sau khi sửa, chỉ ra frame nào không bị unwound trước khi handler chạy.
Đáp án tham khảo

Tại throw site:

text
Global
└── renderUser()
    └── readUser()
        └── readProfile()
            └── readName()   ← throw

Nếu chưa có catch, exception lần lượt đi qua readName() → readProfile() → readUser() → renderUser() rồi tiếp tục ra caller phía ngoài.

Một cách thêm boundary:

js
function renderUser(user) {
  try {
    const name = readUser(user);
    console.log("user:", name);
  } catch (error) {
    console.log("Cannot render user");
  }
}

Khi handler chạy, readName, readProfile và readUser đã rời stack; renderUser vẫn active vì chính invocation này chứa catch boundary.

Lab 3 — Independent: rethrow có chủ đích ​

Viết loadUser() dựa trên API giả lập sau:

js
function fetchUserFromCache() {
  throw new Error("Cache unavailable");
}

Yêu cầu:

  • loadUser() catch lỗi để log cache failed;
  • sau khi log, exception phải tiếp tục propagate ra caller;
  • main() là boundary ngoài cùng và in main recovered.
Đáp án tham khảo
js
function loadUser() {
  try {
    return fetchUserFromCache();
  } catch (error) {
    console.log("cache failed");
    throw error;
  }
}

function main() {
  try {
    loadUser();
  } catch (error) {
    console.log("main recovered");
  }
}

main();

loadUser() có catch nhưng không biến exception thành handled completion cuối cùng; nó throw error lại. Vì vậy propagation tiếp tục tới main().

9. Edge Cases (Các trường hợp ngoại lệ) ​

Edge Case 1 — Catch không đồng nghĩa caller phía dưới resume tại call site ​

js
function outer() {
  try {
    inner();
    console.log("after inner");
  } catch (error) {
    console.log("caught");
  }
}

function inner() {
  throw new Error("boom");
}

Khi inner() throw, outer() không resume tại console.log("after inner"). Control chuyển thẳng tới catch.

Common Misconception (Nhầm lẫn phổ biến)

"Exception được catch nên code ngay sau call site vẫn chạy" là sai. Handler thay đổi control flow. Các statement còn lại trong phần try sau throw path bị bỏ qua.

Edge Case 2 — Catch rồi im lặng có thể làm mất signal lỗi ​

js
function loadConfig() {
  try {
    return parseConfig();
  } catch (error) {
    console.log("failed");
  }
}
js
function loadConfig() {
  try {
    return parseConfig();
  } catch (error) {
    console.log("failed");
    throw error;
  }
}

Hai phiên bản có contract khác nhau. Phiên bản Swallow biến path lỗi thành normal completion của loadConfig() với kết quả undefined nếu không return giá trị khác. Phiên bản Rethrow giữ exception flow để caller phía trên quyết định xử lý.

WARNING

Không có quy tắc "luôn rethrow" hoặc "luôn catch". Điều cần trace là boundary này đang biến đổi control contract như thế nào và caller phía trên đang kỳ vọng behavior gì.

Edge Case 3 — Throw trực tiếp trong cùng frame không cần unwind caller ​

Nếu try và catch nằm trong cùng invocation chứa throw site, exception có thể được xử lý mà không pop function frame đó. Vì vậy câu "mỗi lần throw thì current frame luôn bị pop" là over-simplification sai.

Edge Case 4 — Stack trace format không phải contract cố định ​

Tên function, file path, line/column và cách internal frames được hiển thị có thể khác giữa runtime/tooling. Khi debug, ưu tiên invariant có giá trị hơn: thứ tự caller chain và throw origin mà trace đang cung cấp, sau đó đối chiếu source thực tế.

10. Debug Lab (Bài lab gỡ lỗi) ​

Symptom (Triệu chứng) ​

UI gọi getDisplayName() nhưng đôi lúc cả action bị dừng và dòng render complete không xuất hiện.

Reproduction (Tái hiện lỗi) ​

js
function normalizeUser(user) {
  if (!user) {
    throw new Error("User required");
  }

  return user;
}

function getDisplayName(user) {
  const normalized = normalizeUser(user);
  return normalized.name ?? "Anonymous";
}

function render(user) {
  console.log("render:start");
  const name = getDisplayName(user);
  console.log("name:", name);
  console.log("render complete");
}

render(null);

Evidence (Bằng chứng) ​

  • render:start xuất hiện.
  • name: không xuất hiện.
  • render complete không xuất hiện.
  • Throw origin nằm trong normalizeUser().
  • Caller chain là render() → getDisplayName() → normalizeUser().

Hypothesis (Giả thuyết) ​

normalizeUser() throw, getDisplayName() không có handler, và render() cũng không có handler. Vì vậy exception unwind qua cả hai caller thay vì render() tiếp tục normal flow.

Verification (Xác minh) ​

Trace tại throw site:

text
Global
└── render(null)
    └── getDisplayName(null)
        └── normalizeUser(null)   ← throw

Unwind nếu không có catch:

text
normalizeUser() exits abruptly
        ↓
getDisplayName() exits abruptly
        ↓
render() exits abruptly
        ↓
unhandled outside this chain

Root Cause (Nguyên nhân gốc rễ) ​

Một assumption ở normalizeUser() được biểu diễn bằng exception, nhưng call chain không có boundary phù hợp để chuyển failure này thành behavior mà render() có thể xử lý.

Fix (Sửa lỗi) ​

Ở phạm vi lab này, đặt boundary tại render():

js
function render(user) {
  console.log("render:start");

  try {
    const name = getDisplayName(user);
    console.log("name:", name);
  } catch (error) {
    console.log("Cannot render user");
  }

  console.log("render complete");
}

Bây giờ exception unwind normalizeUser() và getDisplayName(), nhưng dừng tại render().

Prevention (Phòng ngừa) ​

  • Xác định rõ function nào có quyền recover và function nào chỉ nên propagate failure.
  • Test cả success path lẫn failure path.
  • Khi code "bỗng dừng giữa function", trace throw origin và caller chain trước khi thêm try...catch ngẫu nhiên.
  • Không catch chỉ để làm lỗi biến mất; boundary phải có behavior rõ ràng sau khi catch.

Debugging Lens (Góc nhìn gỡ lỗi)

Khi thấy một statement không chạy dù không có return trước nó, hãy kiểm tra xem một callee có thể throw hay không. Exception propagation là một dạng control transfer có thể bỏ qua nhiều statement và nhiều caller frame.

11. Design / Decision Exercise (Bài tập lựa chọn triển khai) ​

Một utility readSettings() có thể throw khi file cấu hình không hợp lệ. Team cân nhắc hai boundary:

js
function readSettings() {
  try {
    return parseSettings();
  } catch (error) {
    return {};
  }
}
js
function readSettings() {
  return parseSettings();
}

function startApp() {
  try {
    const settings = readSettings();
    boot(settings);
  } catch (error) {
    showStartupError();
  }
}

Câu hỏi: Phiên bản nào tốt hơn?

Đáp án tham khảo

Không thể quyết định chỉ bằng syntax.

Ở depth L3–L4, hãy dùng contract lens:

  • Nếu {} là fallback hợp lệ và readSettings() thực sự sở hữu quyết định fallback, boundary thấp có thể phù hợp.
  • Nếu parsing failure làm application không thể boot an toàn, biến lỗi thành {} có thể che mất nguyên nhân và tạo state sai. Boundary cao cho caller có đủ context để quyết định failure behavior.
  • Điểm cần chứng minh là bạn biết catch location thay đổi exception propagation và observable contract.

Bài này chưa yêu cầu thiết kế error architecture cho toàn hệ thống.

Decision Lens (Góc nhìn quyết định)

Đặt catch ở nơi có đủ context để quyết định recover, fallback, transform hay propagate. Đừng chọn boundary chỉ vì đó là nơi dễ thêm try...catch nhất.

12. Production Scenario (Tình huống thực tế) ​

Một checkout flow gọi nhiều lớp utility:

js
function parsePrice(raw) {
  const value = Number(raw);

  if (!Number.isFinite(value)) {
    throw new Error("Invalid price");
  }

  return value;
}

function calculateTotal(item) {
  return parsePrice(item.price) * item.quantity;
}

function submitOrder(item) {
  const total = calculateTotal(item);
  console.log("submit", total);
}

Production nhận một item có price: "unknown". parsePrice() throw, làm calculateTotal() và submitOrder() không hoàn tất theo normal path.

Context: checkout action là user-facing boundary.

Constraint: invalid data phải tạo feedback rõ ràng, không được silently submit NaN và cũng không nên làm cả action chết mà không có message.

Symptom: log submit không xuất hiện; stack trace trỏ từ parsePrice qua calculateTotal tới submitOrder.

Câu hỏi:

  1. Throw origin nằm ở đâu?
  2. Frame nào bị unwound nếu không có catch?
  3. Boundary nào có đủ context để biến technical failure thành user-facing behavior?
  4. Vì sao catch ngay trong parsePrice() rồi trả 0 có thể làm thay đổi business behavior?
Đáp án tham khảo
  1. Throw origin là parsePrice() khi Number.isFinite(value) sai.
  2. Nếu không có handler, parsePrice() → calculateTotal() → submitOrder() đều kết thúc đột ngột theo propagation path.
  3. Một boundary quanh checkout action hoặc submitOrder() thường có nhiều context hơn để quyết định message/recovery so với parser thấp nhất. Lesson này chỉ cần nhận ra boundary ownership, chưa thiết kế UX architecture chi tiết.
  4. Trả 0 biến "invalid price" thành một total có vẻ hợp lệ. Đây không chỉ là xử lý exception; nó thay đổi data/business contract và có thể che lỗi upstream.

Production Risk (Rủi ro production)

Catch quá thấp rồi trả một fallback "cho chạy tiếp" có thể biến failure rõ ràng thành dữ liệu sai âm thầm. Stack unwinding giúp bạn thấy nơi exception đi qua; engineering judgment quyết định boundary nào thực sự có quyền recover.

13. AI-Assisted Exercise (Bài tập với AI) ​

Level 2 — Challenge (Thách thức)

  1. Tự trace trước đoạn a() → b() → c(), trong đó c() throw và a() catch.
  2. Ghi Call Stack tại throw site và Call Stack khi catch của a() bắt đầu chạy.
  3. Hỏi AI: "Explain JavaScript stack unwinding when c() throws, b() has no catch, and a() catches the error. Which frames are removed, and does a() get popped before its catch runs?"
  4. Kiểm tra xem AI có nói sai rằng mọi frame, kể cả frame chứa catch, đều bị pop rồi mới chạy catch hay không.
  5. Verify bằng một runnable example và quan sát output/stack trace trong DevTools hoặc Node.js.
Đáp án tham khảo

Tại throw site:

text
Global
└── a()
    └── b()
        └── c()   ← throw

Nếu c() và b() không có handler, hai invocation đó bị unwound. Khi catch trong a() bắt đầu chạy:

text
Global
└── a()   ← catch đang chạy

Một câu trả lời AI nói "a cũng bị popped, sau đó catch của a chạy" là mental model sai. Handler chạy trong execution của a(); frame này vẫn active tại boundary.

14. Teach Back (Dạy lại) ​

Trong 2 phút, giải thích cho một junior vì sao đoạn code sau không in b:end, nhưng vẫn in a:end:

js
function a() {
  try {
    b();
  } catch (error) {
    console.log("caught");
  }

  console.log("a:end");
}

function b() {
  c();
  console.log("b:end");
}

function c() {
  throw new Error("boom");
}

a();

Bắt buộc dùng đủ các từ: throw site, propagation, stack unwinding, catch boundary, resume.

Mô phỏng

c() là throw site. Vì c() không có catch, exception propagate ra caller và invocation c() kết thúc đột ngột. b() cũng không có catch, nên b() bị stack unwinding trước khi kịp chạy console.log("b:end"). Exception tiếp tục tới a(), nhưng lời gọi b() nằm trong try có catch boundary. Vì vậy frame a() không bị pop; control chuyển vào catch và in caught. Sau khi handler hoàn tất mà không throw thêm, a() resume theo flow sau try...catch, nên console.log("a:end") vẫn chạy.

Gợi ý đánh giá bản thân

  • Bạn có nói rõ frame nào bị pop và frame nào vẫn active không?
  • Bạn có tránh câu mơ hồ "error đi lên" mà không giải thích mechanism không?
  • Bạn có giải thích được vì sao statement sau call site trong try bị bỏ qua nhưng statement sau toàn bộ try...catch vẫn có thể chạy không?

15. Assessment (Đánh giá) ​

Objective (Mục tiêu)Hình thức đánh giáDepth (Độ sâu)
Trace synchronous exception qua 3–4 callsTrace (Theo vết thực thi)L3
Giải thích stack unwinding bằng frame lifecycleExplain (Giải thích)L2–L3
Dự đoán statement bị skip sau throwPrediction (Dự đoán)L3
Xác định catch boundary và stack còn lạiClassification + TraceL3
Phân biệt normal return với exception propagationCompare (So sánh)L3
Debug swallowed/rethrown exceptionDebug (Gỡ lỗi)L4
Chọn catch boundary theo ownership đơn giảnImplementation choiceL3–L4
Dùng stack trace để reconstruct caller chainDebug evidenceL4

16. Exit Criteria (Tiêu chí qua bài) ​

  • [ ] Có thể vẽ Call Stack tại throw site của một nested call 3–4 tầng.
  • [ ] Có thể ghi đúng thứ tự frame bị unwound khi không có handler ở các tầng trung gian.
  • [ ] Có thể xác định chính xác frame chứa catch boundary vẫn active khi handler bắt đầu chạy.
  • [ ] Có thể dự đoán đúng ít nhất 4/5 scenario về statement nào chạy hoặc bị skip sau exception.
  • [ ] Có thể phân biệt normal return với exception propagation bằng control-flow reasoning.
  • [ ] Có thể debug một case exception bị swallow và giải thích observable contract đã thay đổi thế nào.
  • [ ] Có thể giải thích stack trace như evidence của throw origin + caller chain mà không phụ thuộc format cụ thể của một runtime.
  • [ ] Có thể chọn một catch boundary đơn giản dựa trên nơi có đủ context để recover/fallback/propagate.
  • [ ] Không nhầm synchronous exception unwinding với Promise rejection hoặc async callback behavior.

17. Spiral Connection (Liên kết xoắn ốc) ​

Previous (Trước): Lesson 1.5.3 — Recursion đã cho bạn thấy nhiều invocation active cùng lúc và cách Call Stack shrink bằng normal completion theo LIFO. Exception tái sử dụng cùng stack structure nhưng thay đổi lý do frame rời stack: invocation có thể kết thúc đột ngột thay vì return bình thường.

Current (Hiện tại): Exception & Stack Unwinding bổ sung một control-flow path mới vào execution model: throw → propagation → unwind → catch boundary / unhandled. Bạn giờ có thể giải thích vì sao nhiều caller bị bỏ qua statement còn lại và frame nào vẫn tồn tại khi handler chạy.

Next (Tiếp theo): Lesson 1.5.5 — Full Execution Trace sẽ kết hợp Call Stack, value flow, scope, lexical environment, closure và exception thành một bài trace tổng hợp. Exception handling sau đó được spiral lại ở Stage 3 với Promise rejection/async flow, Stage 5 với network failure, Stage 8 với React error handling và các Stage production/reliability ở depth cao hơn.

📴 Offline Mode — Content served from cache